@produtype/core 0.8.2 → 0.10.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 +1 -1
- package/dist/analyzer/analyzeProject.js +49 -2
- package/dist/analyzer/catalogue.js +13 -0
- package/dist/analyzer/detectBilling.js +9 -1
- package/dist/analyzer/detectClientLogic.js +24 -1
- package/dist/analyzer/detectFrontend.js +19 -0
- package/dist/analyzer/detectMobile.js +16 -1
- package/dist/analyzer/detectObservability.js +19 -6
- package/dist/analyzer/detectPackaging.d.ts +3 -0
- package/dist/analyzer/detectPackaging.js +148 -0
- package/dist/analyzer/detectSecurity.js +12 -12
- package/dist/analyzer/importedDependencies.d.ts +9 -0
- package/dist/analyzer/importedDependencies.js +168 -0
- package/dist/analyzer/types.d.ts +14 -0
- package/dist/cli.js +1 -1
- package/dist/expectations/evaluateExpectations.js +16 -1
- package/dist/expectations/productProfiles.d.ts +6 -0
- package/dist/expectations/productProfiles.js +101 -0
- package/dist/expectations/profileSignals.d.ts +14 -0
- package/dist/expectations/profileSignals.js +102 -3
- package/dist/expectations/types.d.ts +11 -2
- package/dist/report/buildReport.js +27 -2
- package/dist/report/executiveSummary.js +3 -0
- package/dist/report/markdownReport.js +11 -0
- package/dist/report/score.d.ts +11 -1
- package/dist/report/score.js +24 -1
- package/dist/report/types.d.ts +5 -1
- package/dist/report/types.js +5 -0
- package/dist/rules/rules.js +36 -7
- package/dist/utils/fileScanner.js +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -174,7 +174,7 @@ prodkit plan ../my-app --output prodkit-plan.md
|
|
|
174
174
|
|
|
175
175
|
_Generated from the analyzer itself — run `npm run docs:stacks` after changing a detector._
|
|
176
176
|
|
|
177
|
-
- **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
|
|
177
|
+
- **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, Litestar, Sanic, Tornado, aiohttp, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, .NET
|
|
178
178
|
- **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
|
|
179
179
|
- **Mobile:** Flutter, React Native, iOS (native), Android (native)
|
|
180
180
|
- **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.analyzeProject = analyzeProject;
|
|
37
|
+
const detectPackaging_1 = require("./detectPackaging");
|
|
38
|
+
const importedDependencies_1 = require("./importedDependencies");
|
|
37
39
|
const path = __importStar(require("path"));
|
|
38
40
|
const zod_1 = require("zod");
|
|
39
41
|
const fileScanner_1 = require("../utils/fileScanner");
|
|
@@ -151,7 +153,20 @@ function isTestOrExamplePath(file) {
|
|
|
151
153
|
* application is not fully understood by being readable — but being readable is the
|
|
152
154
|
* difference between a partial reading and none.
|
|
153
155
|
*/
|
|
154
|
-
|
|
156
|
+
/**
|
|
157
|
+
* `.html` is here, and it is not decoration.
|
|
158
|
+
*
|
|
159
|
+
* Without it a repository whose entire product is one `index.html` with an inline
|
|
160
|
+
* `<script>` — a browser game, a static site, a prototype — was reported as having zero
|
|
161
|
+
* source files, no stack, and a score capped at 39 for being unreadable. `PhaserJS-
|
|
162
|
+
* Spacegame` is exactly that: a complete game, Phaser loaded from a CDN, and nothing
|
|
163
|
+
* for the analyzer to look at. A hardcoded key in an inline script is also a real
|
|
164
|
+
* finding, and every text search here was skipping the file it would be in.
|
|
165
|
+
*
|
|
166
|
+
* `.vue`, `.svelte` and `.astro` were missing for the same reason: a single-file
|
|
167
|
+
* component holds the logic, not just the markup.
|
|
168
|
+
*/
|
|
169
|
+
const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|html?|vue|svelte|astro)$/;
|
|
155
170
|
function pickSource(files) {
|
|
156
171
|
return files.filter((f) => SOURCE_EXTENSIONS.test(f) && !isTestOrExamplePath(f));
|
|
157
172
|
}
|
|
@@ -503,6 +518,30 @@ async function analyzeProject(projectPath) {
|
|
|
503
518
|
mergeDeps(npmDeps, workspace.packageJson?.dependencies);
|
|
504
519
|
mergeDeps(npmDeps, workspace.packageJson?.devDependencies);
|
|
505
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* What the code imports, when nothing declared it.
|
|
523
|
+
*
|
|
524
|
+
* Only reached when the manifests for that language produced nothing at all, so a
|
|
525
|
+
* declared dependency is never overridden by a guess at an import. Seventeen
|
|
526
|
+
* repositories in the verification corpus were called unreadable on the strength of a
|
|
527
|
+
* missing manifest while stating their dependencies in the first three lines of their
|
|
528
|
+
* only source file.
|
|
529
|
+
*/
|
|
530
|
+
const inferredDependencySources = [];
|
|
531
|
+
if (pythonDeps.length === 0) {
|
|
532
|
+
const imported = await (0, importedDependencies_1.pythonImports)(root, sourceFiles);
|
|
533
|
+
if (imported.length > 0) {
|
|
534
|
+
pythonDeps.push(...imported);
|
|
535
|
+
inferredDependencySources.push('Python imports');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
if (Object.keys(npmDeps).length === 0) {
|
|
539
|
+
const imported = await (0, importedDependencies_1.browserImports)(root, sourceFiles);
|
|
540
|
+
for (const name of imported)
|
|
541
|
+
npmDeps[name] = 'imported';
|
|
542
|
+
if (imported.length > 0)
|
|
543
|
+
inferredDependencySources.push('script tags and module imports');
|
|
544
|
+
}
|
|
506
545
|
const workspaceStacks = workspaces.map((workspace) => {
|
|
507
546
|
const wsNpmDeps = {};
|
|
508
547
|
mergeDeps(wsNpmDeps, workspace.packageJson?.dependencies);
|
|
@@ -538,7 +577,7 @@ async function analyzeProject(projectPath) {
|
|
|
538
577
|
npmDeps,
|
|
539
578
|
workspaces,
|
|
540
579
|
};
|
|
541
|
-
const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, mobile, errorReporting] = await Promise.all([
|
|
580
|
+
const [pm, frontend, backend, database, docker, env, auth, security, uploads, gdpr, billing, observability, jobs, marketplace, aiSafety, engagement, deployment, audit, game, clientLogic, mobile, errorReporting, packaging] = await Promise.all([
|
|
542
581
|
(0, detectPackageManager_1.detectPackageManager)(ctx),
|
|
543
582
|
(0, detectFrontend_1.detectFrontend)(ctx),
|
|
544
583
|
(0, detectBackend_1.detectBackend)(ctx),
|
|
@@ -561,6 +600,7 @@ async function analyzeProject(projectPath) {
|
|
|
561
600
|
(0, detectClientLogic_1.detectClientLogic)(ctx),
|
|
562
601
|
(0, detectMobile_1.detectMobile)(ctx),
|
|
563
602
|
(0, detectErrorReporting_1.detectErrorReporting)(ctx),
|
|
603
|
+
(0, detectPackaging_1.detectPackaging)(ctx),
|
|
564
604
|
]);
|
|
565
605
|
const detectors = (0, detectStack_1.mergeDetectors)([
|
|
566
606
|
pm.result,
|
|
@@ -573,6 +613,7 @@ async function analyzeProject(projectPath) {
|
|
|
573
613
|
clientLogic,
|
|
574
614
|
...mobile,
|
|
575
615
|
errorReporting,
|
|
616
|
+
...packaging,
|
|
576
617
|
docker,
|
|
577
618
|
...env,
|
|
578
619
|
...auth,
|
|
@@ -608,6 +649,12 @@ async function analyzeProject(projectPath) {
|
|
|
608
649
|
warnings: [
|
|
609
650
|
...pm.warnings,
|
|
610
651
|
...unreadableLanguages(allFiles).map((entry) => `${entry.files} ${entry.language} files were not analysed: this reading covers only part of the repository`),
|
|
652
|
+
// Said out loud, because a dependency nobody declared is a weaker fact than one
|
|
653
|
+
// that is pinned in a lockfile, and the reader is entitled to know which of the
|
|
654
|
+
// two this reading rests on.
|
|
655
|
+
...(inferredDependencySources.length > 0
|
|
656
|
+
? [`No dependency manifest was found: dependencies were read from ${inferredDependencySources.join(' and ')}, so versions are unknown.`]
|
|
657
|
+
: []),
|
|
611
658
|
],
|
|
612
659
|
workspaces: workspaceStacks,
|
|
613
660
|
files: allFiles,
|
|
@@ -71,6 +71,14 @@ exports.PYTHON_BACKEND_FRAMEWORKS = [
|
|
|
71
71
|
['tornado', 'tornado'],
|
|
72
72
|
['aiohttp', 'aiohttp'],
|
|
73
73
|
['starlette', 'starlette'],
|
|
74
|
+
/**
|
|
75
|
+
* These serve an application over HTTP without calling themselves web frameworks,
|
|
76
|
+
* and a repository built on one was reported as having no backend at all.
|
|
77
|
+
*/
|
|
78
|
+
['streamlit', 'streamlit'],
|
|
79
|
+
['gradio', 'gradio'],
|
|
80
|
+
['dash', 'dash'],
|
|
81
|
+
['chainlit', 'chainlit'],
|
|
74
82
|
];
|
|
75
83
|
/** Frontend frameworks settled by their signature dependency. */
|
|
76
84
|
exports.FRONTEND_FRAMEWORKS = [
|
|
@@ -109,6 +117,11 @@ exports.LANGUAGES = [
|
|
|
109
117
|
];
|
|
110
118
|
/** Display names for ids the tables above produce. */
|
|
111
119
|
const LABELS = {
|
|
120
|
+
streamlit: 'Streamlit',
|
|
121
|
+
gradio: 'Gradio',
|
|
122
|
+
dash: 'Dash',
|
|
123
|
+
chainlit: 'Chainlit',
|
|
124
|
+
html: 'HTML (no framework)',
|
|
112
125
|
next: 'Next.js',
|
|
113
126
|
nestjs: 'NestJS',
|
|
114
127
|
fastify: 'Fastify',
|
|
@@ -24,7 +24,15 @@ async function detectBilling(ctx) {
|
|
|
24
24
|
* a customer or subscription id, or a webhook path. One that only ever writes
|
|
25
25
|
* "Stripe" in prose is talking about Stripe, not billing through it.
|
|
26
26
|
*/
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A Stripe credential, not any constant whose name begins with those six letters.
|
|
29
|
+
*
|
|
30
|
+
* `/STRIPE_[A-Z0-9_]+/` matched `STRIPE_LEN` in Unity's bundled xxHash3, where a
|
|
31
|
+
* stripe is a block of bytes being hashed, and reported a game as taking
|
|
32
|
+
* subscriptions. The word is only evidence of billing when it names a key, a
|
|
33
|
+
* secret, a token or an id.
|
|
34
|
+
*/
|
|
35
|
+
/STRIPE_(?:[A-Z0-9_]*_)?(?:KEY|SECRET|TOKEN|ID|WEBHOOK|PRICE|ACCOUNT|API)[A-Z0-9_]*/,
|
|
28
36
|
/stripeCustomerId/i,
|
|
29
37
|
/stripeSubscriptionId/i,
|
|
30
38
|
/\/webhooks?\/stripe/i,
|
|
@@ -71,11 +71,34 @@ async function detectClientLogic(ctx) {
|
|
|
71
71
|
for (const hit of drawing) {
|
|
72
72
|
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* A render loop. The application is deciding what the screen shows, frame by frame.
|
|
76
|
+
*
|
|
77
|
+
* Counted here because canvas drawing on its own was one signal short of the two this
|
|
78
|
+
* detector needs, so half a dozen browser games in the verification corpus came back
|
|
79
|
+
* as `static-site` — a brochure page — when their whole product is a loop over a
|
|
80
|
+
* canvas. A marketing page that animates on scroll uses the same call and has no
|
|
81
|
+
* canvas, so it still reaches only one signal and is still a static site.
|
|
82
|
+
*/
|
|
83
|
+
const frameLoop = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source,
|
|
84
|
+
/**
|
|
85
|
+
* `setInterval` is deliberately absent. It was here for one revision and was wrong:
|
|
86
|
+
* it matched a countdown in a React modal and a polling sync in an API client,
|
|
87
|
+
* neither of which is the application deciding what the screen shows. Both projects
|
|
88
|
+
* were real products that this demoted to plain client applications, and a demoted
|
|
89
|
+
* profile is judged by fewer expectations — so the change quietly raised their
|
|
90
|
+
* scores. `requestAnimationFrame` means a render loop and nothing else.
|
|
91
|
+
*/
|
|
92
|
+
[/requestAnimationFrame\s*\(/], 6);
|
|
93
|
+
for (const hit of frameLoop) {
|
|
94
|
+
evidence.push({ type: 'snippet', value: hit.snippet, file: hit.file, line: hit.line });
|
|
95
|
+
}
|
|
74
96
|
const signals = [
|
|
75
97
|
stateDeps.length > 0,
|
|
76
98
|
logicDirs.length > 0,
|
|
77
99
|
persistence.length > 0 || dartStorage.length > 0,
|
|
78
100
|
drawing.length > 0,
|
|
101
|
+
frameLoop.length > 0,
|
|
79
102
|
].filter(Boolean).length;
|
|
80
103
|
return {
|
|
81
104
|
key: 'stack.clientLogic',
|
|
@@ -83,6 +106,6 @@ async function detectClientLogic(ctx) {
|
|
|
83
106
|
// banner writing to localStorage, or a marketing page with a hero canvas.
|
|
84
107
|
present: signals >= 2,
|
|
85
108
|
evidence,
|
|
86
|
-
details: { stateDeps, logicDirs: logicDirs.length, persistence: persistence.length + dartStorage.length, drawing: drawing.length, signals },
|
|
109
|
+
details: { stateDeps, logicDirs: logicDirs.length, persistence: persistence.length + dartStorage.length, drawing: drawing.length, frameLoop: frameLoop.length, signals },
|
|
87
110
|
};
|
|
88
111
|
}
|
|
@@ -34,6 +34,25 @@ async function detectFrontend(ctx) {
|
|
|
34
34
|
frameworks.push('flutter');
|
|
35
35
|
evidence.push({ type: 'dependency', value: 'flutter' });
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* A page is a front end, whether or not a framework built it.
|
|
39
|
+
*
|
|
40
|
+
* This list only ever held frameworks, so a repository whose product is `index.html`
|
|
41
|
+
* plus a script had no front end, no stack, and a report that called it unreadable
|
|
42
|
+
* and capped its score at 39. Six of them sit in the verification corpus — browser
|
|
43
|
+
* games and small static sites, each one perfectly legible.
|
|
44
|
+
*
|
|
45
|
+
* Recorded only when no framework claimed the project, because saying "react, html"
|
|
46
|
+
* about a React application is noise: every one of them ships an `index.html`.
|
|
47
|
+
*/
|
|
48
|
+
if (frameworks.length === 0) {
|
|
49
|
+
const pages = ctx.files.source.filter((file) => /\.html?$/i.test(file));
|
|
50
|
+
if (pages.length > 0) {
|
|
51
|
+
frameworks.push('html');
|
|
52
|
+
for (const page of pages.slice(0, 3))
|
|
53
|
+
evidence.push({ type: 'file', value: page, file: page });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
37
56
|
const extras = (0, detectContext_1.hasAnyDep)(ctx, ['react-router-dom', 'axios', 'tailwindcss', 'electron']);
|
|
38
57
|
for (const d of extras) {
|
|
39
58
|
frameworks.push(d);
|
|
@@ -92,8 +92,23 @@ const OFFLINE_DEPS = [
|
|
|
92
92
|
'@tanstack/query-persist-client-core',
|
|
93
93
|
'powersync',
|
|
94
94
|
];
|
|
95
|
+
/**
|
|
96
|
+
* Paths that contain a platform marker without being a project.
|
|
97
|
+
*
|
|
98
|
+
* Every macOS `.bundle`, `.framework`, `.app` and `.dSYM` carries an `Info.plist` by
|
|
99
|
+
* definition, and build caches are full of them. A Unity game was classified as an iOS
|
|
100
|
+
* application on the strength of
|
|
101
|
+
* `Library/BurstCache/JIT/…bundle.dSYM/Contents/Info.plist` — a debug-symbols bundle
|
|
102
|
+
* inside generated output, in a repository whose own engine had already been detected.
|
|
103
|
+
*
|
|
104
|
+
* `Pods`, `DerivedData` and `node_modules` are here for the same reason: they hold
|
|
105
|
+
* other people's projects, and finding one there says nothing about this one.
|
|
106
|
+
*/
|
|
107
|
+
const GENERATED_OR_VENDORED = /(^|\/)(Library|Pods|DerivedData|build|node_modules|dist|out|\.gradle)(\/|$)|\.(bundle|framework|app|dSYM|xcframework)\//i;
|
|
95
108
|
function matchesAny(files, patterns) {
|
|
96
|
-
return files
|
|
109
|
+
return files
|
|
110
|
+
.filter((file) => !GENERATED_OR_VENDORED.test(file))
|
|
111
|
+
.filter((file) => patterns.some((pattern) => pattern.test(file)));
|
|
97
112
|
}
|
|
98
113
|
async function detectMobile(ctx) {
|
|
99
114
|
const iosFiles = matchesAny(ctx.files.all, IOS_MARKERS);
|
|
@@ -8,18 +8,31 @@ async function detectObservability(ctx) {
|
|
|
8
8
|
const logDeps = (0, detectContext_1.hasAnyDep)(ctx, ['winston', 'pino', 'morgan', 'bunyan']);
|
|
9
9
|
const sentryDeps = (0, detectContext_1.hasAnyDep)(ctx, ['@sentry/node', 'sentry-sdk']);
|
|
10
10
|
for (const d of logDeps)
|
|
11
|
-
evidence.push({ type: 'dependency', value: d });
|
|
11
|
+
evidence.push({ type: 'dependency', value: d, claim: 'logging' });
|
|
12
12
|
for (const d of sentryDeps)
|
|
13
|
-
evidence.push({ type: 'dependency', value: d });
|
|
13
|
+
evidence.push({ type: 'dependency', value: d, claim: 'logging' });
|
|
14
14
|
const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/\/(health|healthz|readyz)\b/i, /x-request-id/i, /correlation-id/i, /error\s*handler/i, /RotatingFileHandler/i], 25);
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* One search, three answers, so the claim is decided per hit rather than per search.
|
|
17
|
+
*
|
|
18
|
+
* A `logger.debug(...)` line was being cited as the evidence for a missing health
|
|
19
|
+
* endpoint, which is not an argument about health at all. Whichever pattern matched
|
|
20
|
+
* is what the line is evidence of.
|
|
21
|
+
*/
|
|
22
|
+
for (const m of hits) {
|
|
23
|
+
const claim = /\/(health|healthz|readyz)\b/i.test(m.snippet)
|
|
24
|
+
? 'health'
|
|
25
|
+
: /x-request-id|correlation-id/i.test(m.snippet)
|
|
26
|
+
? 'request-id'
|
|
27
|
+
: 'logging';
|
|
28
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim });
|
|
29
|
+
}
|
|
17
30
|
// A file-routing framework declares the endpoint as a path, not as a string in
|
|
18
31
|
// source: Next's app/api/health/route.ts contains no "/health" to match. Searching
|
|
19
32
|
// only the text reports hardened applications as having no health check.
|
|
20
33
|
const healthFiles = ctx.files.all.filter((file) => /(^|\/)(health|healthz|readyz|liveness|readiness)(\/route|\.[a-z]+)?$|(^|\/)(health|healthz|readyz)\//i.test(file));
|
|
21
34
|
for (const file of healthFiles)
|
|
22
|
-
evidence.push({ type: 'file', value: file });
|
|
35
|
+
evidence.push({ type: 'file', value: file, claim: 'health' });
|
|
23
36
|
const hasHealth = healthFiles.length > 0 || hits.some((h) => /\/(health|healthz|readyz)\b/i.test(h.snippet));
|
|
24
37
|
const hasReqId = hits.some((h) => /x-request-id|correlation-id/i.test(h.snippet));
|
|
25
38
|
// Structured logging without a logging library is still structured logging. What
|
|
@@ -27,7 +40,7 @@ async function detectObservability(ctx) {
|
|
|
27
40
|
// produced them.
|
|
28
41
|
const structuredHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/JSON\.stringify\(\s*\{[^}]*level/i, /logger\.(info|warn|error|debug)\s*\(/, /structuredLog/i], 15);
|
|
29
42
|
for (const m of structuredHits)
|
|
30
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
43
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
|
|
31
44
|
const hasStructuredLogging = logDeps.length > 0 || structuredHits.length > 0;
|
|
32
45
|
return {
|
|
33
46
|
key: 'observability.core',
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectPackaging = detectPackaging;
|
|
4
|
+
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
5
|
+
/**
|
|
6
|
+
* Whether a project is fit to be installed and depended on by someone else.
|
|
7
|
+
*
|
|
8
|
+
* Twenty-five of the seventy-seven repositories in the verification corpus received no
|
|
9
|
+
* profile at all, and a large share of them were libraries and command-line tools:
|
|
10
|
+
* Meta's llama3 reference implementation, a Markdown converter, a photo editor, this
|
|
11
|
+
* project's own AI package. Every profile here assumed a running service with users, so
|
|
12
|
+
* the most common kind of code on a developer's disk had no category — and a product
|
|
13
|
+
* that sells "judged for the type of product it is" said nothing about what they were.
|
|
14
|
+
*
|
|
15
|
+
* What matters for a library is not what matters for a service. Nobody signs in to a
|
|
16
|
+
* package. What decides whether it is fit to publish is whether it says what it is,
|
|
17
|
+
* whether it can be imported, whether its licence permits use at all, and whether
|
|
18
|
+
* anything proves it works.
|
|
19
|
+
*/
|
|
20
|
+
const LICENSE_FILE = /(^|\/)(LICEN[CS]E|COPYING)(\.[a-z]+)?$/i;
|
|
21
|
+
const README_FILE = /(^|\/)README(\.[a-z]+)?$/i;
|
|
22
|
+
const CI_FILE = /(^|\/)(\.github\/workflows\/[^/]+\.ya?ml|\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|Jenkinsfile|\.travis\.yml|\.woodpecker\.ya?ml)$/i;
|
|
23
|
+
/**
|
|
24
|
+
* A test, as opposed to a file with "test" in its name.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately not `/test/`: `src/latest.ts` and `contest.py` both contain it, and a
|
|
27
|
+
* repository does not get credit for a word.
|
|
28
|
+
*/
|
|
29
|
+
const TEST_FILE = /(^|\/)(tests?|__tests__|spec)\/|\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)test_[^/]+\.py$|[^/]+_test\.(py|go|rb)$/i;
|
|
30
|
+
function collect(files, pattern, limit = 3) {
|
|
31
|
+
return files.filter((file) => pattern.test(file)).slice(0, limit);
|
|
32
|
+
}
|
|
33
|
+
async function detectPackaging(ctx) {
|
|
34
|
+
const all = ctx.files.all;
|
|
35
|
+
const licenseFiles = collect(all, LICENSE_FILE);
|
|
36
|
+
const declaredLicense = typeof ctx.packageJson?.license === 'string';
|
|
37
|
+
const licenseEvidence = [
|
|
38
|
+
...licenseFiles.map((file) => ({ type: 'file', value: file, file })),
|
|
39
|
+
...(declaredLicense ? [{ type: 'note', value: 'a license field in package.json' }] : []),
|
|
40
|
+
];
|
|
41
|
+
const readmeFiles = collect(all, README_FILE);
|
|
42
|
+
/**
|
|
43
|
+
* A README that is only a title is not documentation.
|
|
44
|
+
*
|
|
45
|
+
* The threshold is deliberately low — enough prose to say what the thing is and how
|
|
46
|
+
* to install it — because the check is "did anyone write anything", not "is it good".
|
|
47
|
+
*/
|
|
48
|
+
let readmeLength = 0;
|
|
49
|
+
for (const file of readmeFiles) {
|
|
50
|
+
const raw = await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file);
|
|
51
|
+
readmeLength = Math.max(readmeLength, (raw ?? '').trim().length);
|
|
52
|
+
}
|
|
53
|
+
const ciFiles = collect(all, CI_FILE);
|
|
54
|
+
const testFiles = all.filter((file) => TEST_FILE.test(file));
|
|
55
|
+
/**
|
|
56
|
+
* A test runner that something actually invokes.
|
|
57
|
+
*
|
|
58
|
+
* Test files with no way to run them is a different state from a project with a
|
|
59
|
+
* `npm test` that a reader can type, so the two are separated: files alone are
|
|
60
|
+
* `partial`, files plus a runner are `complete`.
|
|
61
|
+
*/
|
|
62
|
+
const scripts = (ctx.packageJson?.scripts ?? {});
|
|
63
|
+
const hasTestScript = typeof scripts.test === 'string' && !/no test specified/i.test(scripts.test);
|
|
64
|
+
const hasPyTestRunner = all.some((f) => /(^|\/)(pytest\.ini|tox\.ini|noxfile\.py)$/i.test(f))
|
|
65
|
+
|| ctx.pythonDeps.some((d) => d === 'pytest' || d === 'nose2' || d === 'unittest2');
|
|
66
|
+
/**
|
|
67
|
+
* Whether the package can be installed and imported.
|
|
68
|
+
*
|
|
69
|
+
* `main`, `module`, `exports` and `bin` are the four ways a Node package says where
|
|
70
|
+
* it starts; `types` is how a TypeScript consumer finds it. A Python package says the
|
|
71
|
+
* same thing through `[project.scripts]` or a `packages` argument in setup.py.
|
|
72
|
+
*/
|
|
73
|
+
const pkg = (ctx.packageJson ?? {});
|
|
74
|
+
const nodeEntrypoints = ['main', 'module', 'exports', 'bin'].filter((key) => pkg[key] !== undefined);
|
|
75
|
+
const hasTypes = pkg.types !== undefined || pkg.typings !== undefined;
|
|
76
|
+
const isPrivate = pkg.private === true;
|
|
77
|
+
let pythonEntrypoints = false;
|
|
78
|
+
for (const file of all.filter((f) => /(^|\/)(pyproject\.toml|setup\.py|setup\.cfg)$/i.test(f)).slice(0, 4)) {
|
|
79
|
+
const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, file)) ?? '';
|
|
80
|
+
if (/\[project\.scripts\]|\[project\.gui-scripts\]|console_scripts|entry_points|packages\s*=|\[project\]/i.test(raw)) {
|
|
81
|
+
pythonEntrypoints = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const hasManifest = ctx.packageJson !== null
|
|
85
|
+
|| all.some((f) => /(^|\/)(pyproject\.toml|setup\.py|go\.mod|Cargo\.toml|composer\.json|\w+\.gemspec)$/i.test(f));
|
|
86
|
+
/**
|
|
87
|
+
* A name and a version. Without them nothing can depend on this, whatever else it
|
|
88
|
+
* does right.
|
|
89
|
+
*/
|
|
90
|
+
const named = typeof pkg.name === 'string' && typeof pkg.version === 'string';
|
|
91
|
+
const described = typeof pkg.description === 'string' && pkg.description.length > 0;
|
|
92
|
+
const sourced = pkg.repository !== undefined || pkg.homepage !== undefined;
|
|
93
|
+
const entrypointCount = nodeEntrypoints.length + (pythonEntrypoints ? 1 : 0);
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
key: 'packaging.manifest',
|
|
97
|
+
present: hasManifest,
|
|
98
|
+
complete: hasManifest && (named || pythonEntrypoints),
|
|
99
|
+
evidence: hasManifest ? [{ type: 'note', value: named ? 'a manifest with a name and a version' : 'a package manifest' }] : [],
|
|
100
|
+
details: { named, described, sourced, private: isPrivate },
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'packaging.entrypoints',
|
|
104
|
+
present: entrypointCount > 0,
|
|
105
|
+
complete: entrypointCount > 0 && (hasTypes || pythonEntrypoints),
|
|
106
|
+
evidence: [
|
|
107
|
+
...nodeEntrypoints.map((key) => ({ type: 'note', value: `package.json declares "${key}"` })),
|
|
108
|
+
...(pythonEntrypoints ? [{ type: 'note', value: 'a Python package or console script declaration' }] : []),
|
|
109
|
+
...(hasTypes ? [{ type: 'note', value: 'TypeScript types are declared' }] : []),
|
|
110
|
+
],
|
|
111
|
+
details: { nodeEntrypoints, hasTypes, pythonEntrypoints },
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: 'packaging.license',
|
|
115
|
+
// A licence file and a declared licence say the same thing; either alone is an
|
|
116
|
+
// answer, and a reader who has one does not need the other to use the code.
|
|
117
|
+
present: licenseFiles.length > 0 || declaredLicense,
|
|
118
|
+
complete: licenseFiles.length > 0 && declaredLicense,
|
|
119
|
+
evidence: licenseEvidence,
|
|
120
|
+
details: { files: licenseFiles.length, declared: declaredLicense },
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
key: 'docs.readme',
|
|
124
|
+
present: readmeFiles.length > 0,
|
|
125
|
+
complete: readmeLength > 400,
|
|
126
|
+
evidence: readmeFiles.map((file) => ({ type: 'file', value: `${file} (${readmeLength} characters)`, file })),
|
|
127
|
+
details: { length: readmeLength },
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
key: 'quality.tests',
|
|
131
|
+
present: testFiles.length > 0,
|
|
132
|
+
complete: testFiles.length > 0 && (hasTestScript || hasPyTestRunner),
|
|
133
|
+
evidence: [
|
|
134
|
+
...testFiles.slice(0, 3).map((file) => ({ type: 'file', value: file, file })),
|
|
135
|
+
...(hasTestScript ? [{ type: 'note', value: 'an "npm test" script' }] : []),
|
|
136
|
+
...(hasPyTestRunner ? [{ type: 'note', value: 'a Python test runner' }] : []),
|
|
137
|
+
],
|
|
138
|
+
details: { files: testFiles.length, runner: hasTestScript || hasPyTestRunner },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
key: 'quality.ci',
|
|
142
|
+
present: ciFiles.length > 0,
|
|
143
|
+
complete: ciFiles.length > 0 && testFiles.length > 0,
|
|
144
|
+
evidence: ciFiles.map((file) => ({ type: 'file', value: file, file })),
|
|
145
|
+
details: { files: ciFiles.length },
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}
|
|
@@ -66,13 +66,13 @@ async function detectSecurity(ctx) {
|
|
|
66
66
|
const rateLimitSignals = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/rateLimit\s*\(/, /rate_?limit/i, /Retry-After/i, /\b429\b/, /TooManyRequests/i], 20);
|
|
67
67
|
const rateLimit = rateLimitDep || rateLimitSignals.length > 0;
|
|
68
68
|
if (helmetDep)
|
|
69
|
-
evidence.push({ type: 'dependency', value: 'helmet' });
|
|
69
|
+
evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
|
|
70
70
|
if (rateLimitDep)
|
|
71
|
-
evidence.push({ type: 'dependency', value: 'rate limiting package' });
|
|
71
|
+
evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
|
|
72
72
|
for (const m of headerSignals)
|
|
73
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
73
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
|
|
74
74
|
for (const m of rateLimitSignals)
|
|
75
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
75
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
|
|
76
76
|
const corsLoose = [];
|
|
77
77
|
const corsStrict = [];
|
|
78
78
|
for (const file of source) {
|
|
@@ -96,18 +96,18 @@ async function detectSecurity(ctx) {
|
|
|
96
96
|
corsStrict.push(...detected.strict);
|
|
97
97
|
}
|
|
98
98
|
for (const m of corsLoose)
|
|
99
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
99
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
|
|
100
100
|
for (const m of corsStrict)
|
|
101
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
101
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
|
|
102
102
|
const webhookSig = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/constructEvent\(/, /webhook.*signature/i], 10);
|
|
103
103
|
const bodyLimit = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/express\.json\(\s*\{[^}]*limit\s*:/i], 10);
|
|
104
104
|
const contentTypeCheck = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/content-type/i, /req\.is\(/], 10);
|
|
105
105
|
for (const m of webhookSig)
|
|
106
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
106
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'webhook-signature' });
|
|
107
107
|
for (const m of bodyLimit)
|
|
108
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
108
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'body-size' });
|
|
109
109
|
for (const m of contentTypeCheck)
|
|
110
|
-
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
110
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'content-type' });
|
|
111
111
|
const djangoSettings = ctx.files.all.find((f) => f.endsWith('settings.py'));
|
|
112
112
|
let djangoDebugTrue = false;
|
|
113
113
|
let djangoSecureCookies = true;
|
|
@@ -115,15 +115,15 @@ async function detectSecurity(ctx) {
|
|
|
115
115
|
const text = (await (0, readTextFileSafe_1.readTextFileSafe)(ctx.root, djangoSettings)) ?? '';
|
|
116
116
|
if (/DEBUG\s*=\s*True/.test(text)) {
|
|
117
117
|
djangoDebugTrue = true;
|
|
118
|
-
evidence.push({ type: 'snippet', value: 'DEBUG = True', file: djangoSettings });
|
|
118
|
+
evidence.push({ type: 'snippet', value: 'DEBUG = True', file: djangoSettings, claim: 'django-debug' });
|
|
119
119
|
}
|
|
120
120
|
const insecureSession = /SESSION_COOKIE_SECURE\s*=\s*False/.test(text);
|
|
121
121
|
const insecureCsrf = /CSRF_COOKIE_SECURE\s*=\s*False/.test(text);
|
|
122
122
|
djangoSecureCookies = !(insecureSession || insecureCsrf);
|
|
123
123
|
if (insecureSession)
|
|
124
|
-
evidence.push({ type: 'snippet', value: 'SESSION_COOKIE_SECURE = False', file: djangoSettings });
|
|
124
|
+
evidence.push({ type: 'snippet', value: 'SESSION_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
|
|
125
125
|
if (insecureCsrf)
|
|
126
|
-
evidence.push({ type: 'snippet', value: 'CSRF_COOKIE_SECURE = False', file: djangoSettings });
|
|
126
|
+
evidence.push({ type: 'snippet', value: 'CSRF_COOKIE_SECURE = False', file: djangoSettings, claim: 'django-cookies' });
|
|
127
127
|
}
|
|
128
128
|
return {
|
|
129
129
|
key: 'security.core',
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Packages a Python project imports, when no requirements file declared any. */
|
|
2
|
+
export declare function pythonImports(root: string, sourceFiles: string[]): Promise<string[]>;
|
|
3
|
+
/**
|
|
4
|
+
* Packages a browser project loads, when no package.json declared any.
|
|
5
|
+
*
|
|
6
|
+
* A game served as `index.html` plus two scripts has no package manager and never will;
|
|
7
|
+
* its dependency list is the set of `<script src>` tags at the top of the page.
|
|
8
|
+
*/
|
|
9
|
+
export declare function browserImports(root: string, sourceFiles: string[]): Promise<string[]>;
|