@produtype/core 1.5.0 → 1.7.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/README.md +1 -1
- package/dist/analyzer/absenceEvidence.d.ts +11 -0
- package/dist/analyzer/absenceEvidence.js +17 -0
- package/dist/analyzer/analyzeProject.js +48 -2
- package/dist/analyzer/catalogue.d.ts +10 -0
- package/dist/analyzer/catalogue.js +28 -1
- package/dist/analyzer/detectAuth.js +26 -1
- package/dist/analyzer/detectBackend.js +16 -0
- package/dist/analyzer/detectContext.d.ts +7 -0
- package/dist/analyzer/detectContext.js +9 -0
- package/dist/analyzer/detectDatabase.js +16 -0
- package/dist/analyzer/detectDeployment.js +1 -1
- package/dist/analyzer/detectDocker.js +1 -1
- package/dist/analyzer/detectObservability.js +11 -2
- package/dist/analyzer/detectPackageManager.js +2 -0
- package/dist/analyzer/detectSecurity.js +49 -3
- package/dist/analyzer/proseLines.d.ts +1 -1
- package/dist/analyzer/proseLines.js +37 -1
- package/dist/analyzer/types.d.ts +10 -1
- package/dist/report/buildReport.js +47 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -191,7 +191,7 @@ prodkit plan ../my-app --output prodkit-plan.md
|
|
|
191
191
|
|
|
192
192
|
_Generated from the analyzer itself — run `npm run docs:stacks` after changing a detector._
|
|
193
193
|
|
|
194
|
-
- **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, aiohttp, Litestar, Sanic, Tornado, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Axum, Actix Web, Rocket, Warp, Tide, Poem, Salvo, Tower HTTP, Hyper, Spring Boot, Quarkus, Micronaut, Ktor, Javalin, Vert.x, Dropwizard, Helidon, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, Cloudflare Workers
|
|
194
|
+
- **Backend:** Express, Next.js, NestJS, Fastify, Hono, Elysia, Koa, AdonisJS, SvelteKit, Remix, Nuxt, Nitro, Astro, Django, Flask, FastAPI, aiohttp, Litestar, Sanic, Tornado, Starlette, Streamlit, Gradio, Dash, Chainlit, Gin, Echo, Fiber, chi, Gorilla, Beego, Go, Axum, Actix Web, Rocket, Warp, Tide, Poem, Salvo, Tower HTTP, Hyper, Spring Boot, Quarkus, Micronaut, Ktor, Javalin, Vert.x, Dropwizard, Helidon, Rails, Sinatra, Hanami, Roda, Grape, Ruby, Phoenix, Plug, Bandit, Laravel, Symfony, Slim, CodeIgniter, CakePHP, Yii, PHP, ASP.NET Core, Cloudflare Workers
|
|
195
195
|
- **Frontend:** React, Vite, Vue, Nuxt, Svelte, Angular, Astro, Solid, Qwik, Preact, Remix, htmx, Tailwind CSS, Electron
|
|
196
196
|
- **Mobile:** Flutter, React Native, iOS (native), Android (native), SwiftUI, UIKit, Jetpack Compose, Android views
|
|
197
197
|
- **Databases:** Postgres, MySQL, SQLite, SQL Server, MongoDB, Redis, Firestore, DynamoDB, Convex
|
|
@@ -25,4 +25,15 @@ export declare function searchedFor(what: string, terms: string[], claim?: strin
|
|
|
25
25
|
* it say so, so that a reader whose roles are called `capabilities` can see why they
|
|
26
26
|
* were missed and tell us we are wrong.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* The same, for a look that needed no reader.
|
|
30
|
+
*
|
|
31
|
+
* A search over the list of file names answers whatever language the files are
|
|
32
|
+
* written in. Separated so that the report can withdraw the claims a blind reader
|
|
33
|
+
* made without withdrawing the ones anybody could check — see
|
|
34
|
+
* `onlyEvidencedByASearchThatCouldNotRead` in the report.
|
|
35
|
+
*/
|
|
36
|
+
export declare function searchedFileNamesFor(what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
28
37
|
export declare function evidenceOrSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
38
|
+
/** `evidenceOrSearch` for a search that only had to read file names. */
|
|
39
|
+
export declare function evidenceOrFileNameSearch(evidence: DetectorEvidence[], what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.searchedFor = searchedFor;
|
|
4
|
+
exports.searchedFileNamesFor = searchedFileNamesFor;
|
|
4
5
|
exports.evidenceOrSearch = evidenceOrSearch;
|
|
6
|
+
exports.evidenceOrFileNameSearch = evidenceOrFileNameSearch;
|
|
5
7
|
/**
|
|
6
8
|
* What was looked for, when nothing was found.
|
|
7
9
|
*
|
|
@@ -36,6 +38,21 @@ function searchedFor(what, terms, claim) {
|
|
|
36
38
|
* it say so, so that a reader whose roles are called `capabilities` can see why they
|
|
37
39
|
* were missed and tell us we are wrong.
|
|
38
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* The same, for a look that needed no reader.
|
|
43
|
+
*
|
|
44
|
+
* A search over the list of file names answers whatever language the files are
|
|
45
|
+
* written in. Separated so that the report can withdraw the claims a blind reader
|
|
46
|
+
* made without withdrawing the ones anybody could check — see
|
|
47
|
+
* `onlyEvidencedByASearchThatCouldNotRead` in the report.
|
|
48
|
+
*/
|
|
49
|
+
function searchedFileNamesFor(what, terms, claim) {
|
|
50
|
+
return searchedFor(what, terms, claim).map((item) => ({ ...item, overFileNames: true }));
|
|
51
|
+
}
|
|
39
52
|
function evidenceOrSearch(evidence, what, terms, claim) {
|
|
40
53
|
return evidence.length > 0 ? evidence : searchedFor(what, terms, claim);
|
|
41
54
|
}
|
|
55
|
+
/** `evidenceOrSearch` for a search that only had to read file names. */
|
|
56
|
+
function evidenceOrFileNameSearch(evidence, what, terms, claim) {
|
|
57
|
+
return evidence.length > 0 ? evidence : searchedFileNamesFor(what, terms, claim);
|
|
58
|
+
}
|
|
@@ -306,7 +306,7 @@ function isTestOrExamplePath(file) {
|
|
|
306
306
|
* `.vue`, `.svelte` and `.astro` were missing for the same reason: a single-file
|
|
307
307
|
* component holds the logic, not just the markup.
|
|
308
308
|
*/
|
|
309
|
-
const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|html?|vue|svelte|astro)$/;
|
|
309
|
+
const SOURCE_EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rb|java|cs|rs|kt|swift|dart|ex|exs|html?|vue|svelte|astro)$/;
|
|
310
310
|
function pickSource(files) {
|
|
311
311
|
return files.filter((f) => SOURCE_EXTENSIONS.test(f) && !isTestOrExamplePath(f));
|
|
312
312
|
}
|
|
@@ -321,7 +321,6 @@ function pickSource(files) {
|
|
|
321
321
|
* Being wrong is recoverable. Being wrong while announcing no reservations is not.
|
|
322
322
|
*/
|
|
323
323
|
const KNOWN_UNREADABLE = [
|
|
324
|
-
[/\.(ex|exs)$/, 'Elixir'],
|
|
325
324
|
[/\.(scala|sc)$/, 'Scala'],
|
|
326
325
|
[/\.(clj|cljs)$/, 'Clojure'],
|
|
327
326
|
[/\.(cpp|cc|hpp)$/, 'C++'],
|
|
@@ -595,6 +594,51 @@ async function analyzeProject(projectPath) {
|
|
|
595
594
|
}
|
|
596
595
|
}
|
|
597
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* mix.exs, read for the packages an Elixir project depends on.
|
|
599
|
+
*
|
|
600
|
+
* plausible is 1257 Elixir files and the report had no score at all: no backend, no
|
|
601
|
+
* database, no package manager, and an honest note saying the language was not read.
|
|
602
|
+
* The note was the right answer to give while it was true — but Phoenix, Ecto,
|
|
603
|
+
* bcrypt_elixir, cors_plug and nimble_totp are all in its manifest, and every one of
|
|
604
|
+
* them is a package name nobody at plausible invented.
|
|
605
|
+
*
|
|
606
|
+
* Mix declares dependencies as tuples in a function rather than as a data file:
|
|
607
|
+
* `{:phoenix, "~> 1.8.2"}` and `{:location, git: "..."}` are both entries, and
|
|
608
|
+
* `{:credo, "~> 1.7", only: [:dev, :test]}` is one the product does not ship. The
|
|
609
|
+
* `only:` option is the same distinction `runtimeRustDeps` draws, written in Elixir.
|
|
610
|
+
*
|
|
611
|
+
* Read from inside the `deps` function and nowhere else. A tuple beginning with an
|
|
612
|
+
* atom is ordinary Elixir and appears all over a manifest: plausible's release
|
|
613
|
+
* configuration holds `{:system, "RELEASE_ROOT", ...}` and its dialyzer settings
|
|
614
|
+
* `{:no_warn, "priv/plts/dialyzer.plt"}`, and both were being read as packages.
|
|
615
|
+
* `deps: deps()` in `project/0` is Mix's own convention, so the function is where
|
|
616
|
+
* the list is.
|
|
617
|
+
*/
|
|
618
|
+
const elixirDeps = [];
|
|
619
|
+
const runtimeElixirDeps = [];
|
|
620
|
+
for (const file of ownManifests.filter((f) => /(^|\/)mix\.exs$/.test(f))) {
|
|
621
|
+
const raw = (await (0, readTextFileSafe_1.readTextFileSafe)(root, file)) ?? '';
|
|
622
|
+
/**
|
|
623
|
+
* An entry can run over several lines — plausible writes four of its OpenTelemetry
|
|
624
|
+
* dependencies with the git ref on lines of their own — so `only:` is looked for
|
|
625
|
+
* in the whole tuple rather than on the line that opens it.
|
|
626
|
+
*/
|
|
627
|
+
const opener = /^(\s*)defp?\s+deps\s+do\s*$/m.exec(raw);
|
|
628
|
+
if (!opener)
|
|
629
|
+
continue;
|
|
630
|
+
const lines = raw.slice(opener.index).split('\n');
|
|
631
|
+
const closer = lines.findIndex((line, i) => i > 0 && line === `${opener[1]}end`);
|
|
632
|
+
const block = lines.slice(1, closer === -1 ? undefined : closer).join('\n');
|
|
633
|
+
const entries = block.matchAll(/\{\s*:([a-z][a-z0-9_]*)\s*(,[\s\S]*?)?\}/g);
|
|
634
|
+
for (const entry of entries) {
|
|
635
|
+
const name = entry[1].toLowerCase();
|
|
636
|
+
const options = entry[2] ?? '';
|
|
637
|
+
elixirDeps.push(name);
|
|
638
|
+
if (!/\bonly:\s*(?:\[[^\]]*\]|:[a-z_]+)/.test(options))
|
|
639
|
+
runtimeElixirDeps.push(name);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
598
642
|
/**
|
|
599
643
|
* pubspec.yaml, read for its two dependency blocks.
|
|
600
644
|
*
|
|
@@ -868,6 +912,8 @@ async function analyzeProject(projectPath) {
|
|
|
868
912
|
goDeps: unique(goDeps),
|
|
869
913
|
rustDeps: unique(rustDeps),
|
|
870
914
|
runtimeRustDeps: unique(runtimeRustDeps),
|
|
915
|
+
elixirDeps: unique(elixirDeps),
|
|
916
|
+
runtimeElixirDeps: unique(runtimeElixirDeps),
|
|
871
917
|
rubyDeps: unique(rubyDeps),
|
|
872
918
|
dotnetDeps: unique(dotnetDeps),
|
|
873
919
|
dotnetWebSdk,
|
|
@@ -46,6 +46,16 @@ export declare const JVM_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
|
|
|
46
46
|
export declare const JVM_DATABASES: Array<[string, string[]]>;
|
|
47
47
|
/** Ruby frameworks, read from the Gemfile. */
|
|
48
48
|
export declare const RUBY_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
|
|
49
|
+
/**
|
|
50
|
+
* Elixir frameworks, read from mix.exs.
|
|
51
|
+
*
|
|
52
|
+
* Phoenix is the answer in nearly every case. Plug is the layer underneath it and a
|
|
53
|
+
* real answer on its own — an Elixir service that serves requests without Phoenix
|
|
54
|
+
* serves them through Plug — and Bandit and Cowboy are the servers that run it.
|
|
55
|
+
*/
|
|
56
|
+
export declare const ELIXIR_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
|
|
57
|
+
/** Elixir data stores, read from mix.exs. */
|
|
58
|
+
export declare const ELIXIR_DATABASES: Array<[string, string[]]>;
|
|
49
59
|
/** PHP frameworks, read from composer.json. */
|
|
50
60
|
export declare const PHP_BACKEND_FRAMEWORKS: Array<[string, string[]]>;
|
|
51
61
|
/**
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* that loses users.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
-
exports.LANGUAGES = exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.JVM_DATABASES = exports.JVM_BACKEND_FRAMEWORKS = exports.RUST_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
|
|
18
|
+
exports.LANGUAGES = exports.FRONTEND_FRAMEWORKS = exports.PYTHON_BACKEND_FRAMEWORKS = exports.PHP_BACKEND_FRAMEWORKS = exports.ELIXIR_DATABASES = exports.ELIXIR_BACKEND_FRAMEWORKS = exports.RUBY_BACKEND_FRAMEWORKS = exports.JVM_DATABASES = exports.JVM_BACKEND_FRAMEWORKS = exports.RUST_BACKEND_FRAMEWORKS = exports.GO_BACKEND_FRAMEWORKS = exports.NODE_BACKEND_FRAMEWORKS = void 0;
|
|
19
19
|
exports.labelFor = labelFor;
|
|
20
20
|
exports.hasLabel = hasLabel;
|
|
21
21
|
exports.supportedStacks = supportedStacks;
|
|
@@ -110,6 +110,27 @@ exports.RUBY_BACKEND_FRAMEWORKS = [
|
|
|
110
110
|
['roda', ['roda']],
|
|
111
111
|
['grape', ['grape']],
|
|
112
112
|
];
|
|
113
|
+
/**
|
|
114
|
+
* Elixir frameworks, read from mix.exs.
|
|
115
|
+
*
|
|
116
|
+
* Phoenix is the answer in nearly every case. Plug is the layer underneath it and a
|
|
117
|
+
* real answer on its own — an Elixir service that serves requests without Phoenix
|
|
118
|
+
* serves them through Plug — and Bandit and Cowboy are the servers that run it.
|
|
119
|
+
*/
|
|
120
|
+
exports.ELIXIR_BACKEND_FRAMEWORKS = [
|
|
121
|
+
['phoenix', ['phoenix']],
|
|
122
|
+
['plug', ['plug', 'plug_cowboy']],
|
|
123
|
+
['bandit', ['bandit']],
|
|
124
|
+
];
|
|
125
|
+
/** Elixir data stores, read from mix.exs. */
|
|
126
|
+
exports.ELIXIR_DATABASES = [
|
|
127
|
+
['postgres', ['postgrex']],
|
|
128
|
+
['mysql', ['myxql']],
|
|
129
|
+
['sqlite', ['ecto_sqlite3', 'exqlite']],
|
|
130
|
+
['clickhouse', ['ecto_ch', 'ch']],
|
|
131
|
+
['redis', ['redix']],
|
|
132
|
+
['mongodb', ['mongodb_driver']],
|
|
133
|
+
];
|
|
113
134
|
/** PHP frameworks, read from composer.json. */
|
|
114
135
|
exports.PHP_BACKEND_FRAMEWORKS = [
|
|
115
136
|
['laravel', ['laravel/framework', 'laravel/laravel']],
|
|
@@ -172,6 +193,7 @@ exports.LANGUAGES = [
|
|
|
172
193
|
{ id: 'kotlin', label: 'Kotlin', extensions: /\.(kt|kts)$/ },
|
|
173
194
|
{ id: 'swift', label: 'Swift', extensions: /\.swift$/ },
|
|
174
195
|
{ id: 'dart', label: 'Dart', extensions: /\.dart$/ },
|
|
196
|
+
{ id: 'elixir', label: 'Elixir', extensions: /\.exs?$/ },
|
|
175
197
|
];
|
|
176
198
|
/** Display names for ids the tables above produce. */
|
|
177
199
|
const LABELS = {
|
|
@@ -220,6 +242,10 @@ const LABELS = {
|
|
|
220
242
|
hanami: 'Hanami',
|
|
221
243
|
roda: 'Roda',
|
|
222
244
|
grape: 'Grape',
|
|
245
|
+
phoenix: 'Phoenix',
|
|
246
|
+
plug: 'Plug',
|
|
247
|
+
bandit: 'Bandit',
|
|
248
|
+
clickhouse: 'ClickHouse',
|
|
223
249
|
laravel: 'Laravel',
|
|
224
250
|
symfony: 'Symfony',
|
|
225
251
|
slim: 'Slim',
|
|
@@ -346,6 +372,7 @@ function supportedStacks() {
|
|
|
346
372
|
...entries(exports.JVM_BACKEND_FRAMEWORKS.map(([id]) => id)),
|
|
347
373
|
...entries(exports.RUBY_BACKEND_FRAMEWORKS.map(([id]) => id)),
|
|
348
374
|
{ id: 'ruby', label: 'Ruby', detectedFrom: 'a Gemfile with no web framework in it' },
|
|
375
|
+
...entries(exports.ELIXIR_BACKEND_FRAMEWORKS.map(([id]) => id)),
|
|
349
376
|
...entries(exports.PHP_BACKEND_FRAMEWORKS.map(([id]) => id)),
|
|
350
377
|
{ id: 'php', label: 'PHP', detectedFrom: 'PHP sources with no framework in composer.json' },
|
|
351
378
|
{ id: 'aspnet-core', label: 'ASP.NET Core', detectedFrom: 'the Microsoft.NET.Sdk.Web SDK attribute' },
|
|
@@ -81,6 +81,7 @@ async function detectAuth(ctx) {
|
|
|
81
81
|
...(0, detectContext_1.hasAnyRustDep)(ctx, ['oauth2', 'openidconnect']),
|
|
82
82
|
...(0, detectContext_1.hasAnyGradleDep)(ctx, ['spring-boot-starter-oauth2-client', 'com.okta.spring']),
|
|
83
83
|
...(0, detectContext_1.hasAnyDotnetDep)(ctx, ['Microsoft.AspNetCore.Authentication.OpenIdConnect', 'Microsoft.Identity.Web']),
|
|
84
|
+
...(0, detectContext_1.hasAnyElixirDep)(ctx, ['ueberauth', 'assent', 'openid_connect']),
|
|
84
85
|
];
|
|
85
86
|
/** And a password of its own, which is what makes a reset flow something to have. */
|
|
86
87
|
const storesAPasswordItself = [
|
|
@@ -89,6 +90,7 @@ async function detectAuth(ctx) {
|
|
|
89
90
|
...(0, detectContext_1.hasAnyRustDep)(ctx, ['argon2', 'rust-argon2', 'bcrypt', 'scrypt', 'password-hash', 'pbkdf2']),
|
|
90
91
|
...(0, detectContext_1.hasAnyGradleDep)(ctx, ['spring-security-crypto', 'org.mindrot:jbcrypt']),
|
|
91
92
|
...(0, detectContext_1.hasAnyDotnetDep)(ctx, ['Microsoft.AspNetCore.Identity']),
|
|
93
|
+
...(0, detectContext_1.hasAnyElixirDep)(ctx, ['bcrypt_elixir', 'argon2_elixir', 'pbkdf2_elixir']),
|
|
92
94
|
];
|
|
93
95
|
/**
|
|
94
96
|
* The packages that do the authenticating, as distinct from the words people use
|
|
@@ -120,13 +122,36 @@ async function detectAuth(ctx) {
|
|
|
120
122
|
'@oslojs/crypto',
|
|
121
123
|
'@auth/core',
|
|
122
124
|
];
|
|
125
|
+
/**
|
|
126
|
+
* The same question in Elixir, where the packages have their own names.
|
|
127
|
+
*
|
|
128
|
+
* `bcrypt_elixir` hashes the password, `guardian` and `pow` hold the session,
|
|
129
|
+
* `ueberauth` hands identity to a provider. plausible declares three of them and
|
|
130
|
+
* reported no authentication at all, because none of these words appears in any of
|
|
131
|
+
* the lists above.
|
|
132
|
+
*/
|
|
133
|
+
const elixirAuthDeps = (0, detectContext_1.hasAnyElixirDep)(ctx, [
|
|
134
|
+
'bcrypt_elixir',
|
|
135
|
+
'argon2_elixir',
|
|
136
|
+
'pbkdf2_elixir',
|
|
137
|
+
'guardian',
|
|
138
|
+
'pow',
|
|
139
|
+
'ueberauth',
|
|
140
|
+
'assent',
|
|
141
|
+
'joken',
|
|
142
|
+
]);
|
|
123
143
|
const authDeps = [
|
|
124
144
|
...(0, detectContext_1.hasAnyDep)(ctx, AUTH_PACKAGES),
|
|
125
145
|
...managedAuthDeps,
|
|
126
146
|
...managedAuthPyDeps,
|
|
147
|
+
...elixirAuthDeps,
|
|
127
148
|
];
|
|
128
149
|
const sessionDeps = [...(0, detectContext_1.hasAnyDep)(ctx, ['express-session', 'cookie-session']), ...managedAuthDeps];
|
|
129
|
-
const twoFaDeps =
|
|
150
|
+
const twoFaDeps = [
|
|
151
|
+
...(0, detectContext_1.hasAnyDep)(ctx, ['speakeasy', 'pyotp', 'qrcode', '@simplewebauthn/server', 'otplib']),
|
|
152
|
+
/** `nimble_totp` is the Elixir one, and plausible ships it. */
|
|
153
|
+
...(0, detectContext_1.hasAnyElixirDep)(ctx, ['nimble_totp']),
|
|
154
|
+
];
|
|
130
155
|
const routeSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
131
156
|
/\/(login|register|logout|signin|signup|sign-in|sign-up)\b/i,
|
|
132
157
|
/requireAuth/i,
|
|
@@ -154,6 +154,22 @@ async function detectBackend(ctx) {
|
|
|
154
154
|
for (const dep of hits)
|
|
155
155
|
evidence.push({ type: 'dependency', value: dep });
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Elixir, read from mix.exs.
|
|
159
|
+
*
|
|
160
|
+
* No fallback beside it, for Rust's reason rather than Go's: Elixir's standard
|
|
161
|
+
* library has no HTTP server, so a mix project with no Phoenix, Plug or Bandit in
|
|
162
|
+
* it is a library or a release tool. Phoenix is what serves the requests when
|
|
163
|
+
* anything does.
|
|
164
|
+
*/
|
|
165
|
+
for (const [framework, deps] of catalogue_1.ELIXIR_BACKEND_FRAMEWORKS) {
|
|
166
|
+
const hits = (0, detectContext_1.hasAnyRuntimeElixirDep)(ctx, deps);
|
|
167
|
+
if (!hits.length)
|
|
168
|
+
continue;
|
|
169
|
+
frameworks.push(framework);
|
|
170
|
+
for (const dep of hits)
|
|
171
|
+
evidence.push({ type: 'dependency', value: dep });
|
|
172
|
+
}
|
|
157
173
|
/**
|
|
158
174
|
* A Gemfile is not a Ruby backend on its own.
|
|
159
175
|
*
|
|
@@ -28,6 +28,10 @@ export interface DetectContext {
|
|
|
28
28
|
goDeps: string[];
|
|
29
29
|
/** Crate names from Cargo.toml, lowercase. */
|
|
30
30
|
rustDeps: string[];
|
|
31
|
+
/** Packages named in mix.exs. */
|
|
32
|
+
elixirDeps: string[];
|
|
33
|
+
/** The ones without an `only:` option, so the ones the product ships. */
|
|
34
|
+
runtimeElixirDeps: string[];
|
|
31
35
|
/** Of those, the ones the product ships with rather than only builds and tests with. */
|
|
32
36
|
runtimeRustDeps: string[];
|
|
33
37
|
/** Gem names from the Gemfile, lowercase. */
|
|
@@ -76,6 +80,9 @@ export declare function hasRuntimePyDep(ctx: DetectContext, name: string): boole
|
|
|
76
80
|
export declare function hasAnyDep(ctx: DetectContext, names: string[]): string[];
|
|
77
81
|
export declare function hasPyDep(ctx: DetectContext, name: string): boolean;
|
|
78
82
|
export declare function hasAnyRustDep(ctx: DetectContext, names: string[]): string[];
|
|
83
|
+
export declare function hasAnyElixirDep(ctx: DetectContext, names: string[]): string[];
|
|
84
|
+
/** The Elixir counterpart: shipped, not merely declared. */
|
|
85
|
+
export declare function hasAnyRuntimeElixirDep(ctx: DetectContext, names: string[]): string[];
|
|
79
86
|
/** The Rust counterpart of `hasRuntimeDep`: shipped, not merely present. */
|
|
80
87
|
export declare function hasAnyRuntimeRustDep(ctx: DetectContext, names: string[]): string[];
|
|
81
88
|
export declare function hasAnyPyDep(ctx: DetectContext, names: string[]): string[];
|
|
@@ -6,6 +6,8 @@ exports.hasRuntimePyDep = hasRuntimePyDep;
|
|
|
6
6
|
exports.hasAnyDep = hasAnyDep;
|
|
7
7
|
exports.hasPyDep = hasPyDep;
|
|
8
8
|
exports.hasAnyRustDep = hasAnyRustDep;
|
|
9
|
+
exports.hasAnyElixirDep = hasAnyElixirDep;
|
|
10
|
+
exports.hasAnyRuntimeElixirDep = hasAnyRuntimeElixirDep;
|
|
9
11
|
exports.hasAnyRuntimeRustDep = hasAnyRuntimeRustDep;
|
|
10
12
|
exports.hasAnyPyDep = hasAnyPyDep;
|
|
11
13
|
exports.hasPhpDep = hasPhpDep;
|
|
@@ -41,6 +43,13 @@ function hasPyDep(ctx, name) {
|
|
|
41
43
|
function hasAnyRustDep(ctx, names) {
|
|
42
44
|
return names.filter((name) => ctx.rustDeps.includes(name.toLowerCase()));
|
|
43
45
|
}
|
|
46
|
+
function hasAnyElixirDep(ctx, names) {
|
|
47
|
+
return names.filter((name) => ctx.elixirDeps.includes(name.toLowerCase()));
|
|
48
|
+
}
|
|
49
|
+
/** The Elixir counterpart: shipped, not merely declared. */
|
|
50
|
+
function hasAnyRuntimeElixirDep(ctx, names) {
|
|
51
|
+
return names.filter((name) => ctx.runtimeElixirDeps.includes(name.toLowerCase()));
|
|
52
|
+
}
|
|
44
53
|
/** The Rust counterpart of `hasRuntimeDep`: shipped, not merely present. */
|
|
45
54
|
function hasAnyRuntimeRustDep(ctx, names) {
|
|
46
55
|
return names.filter((name) => ctx.runtimeRustDeps.includes(name.toLowerCase()));
|
|
@@ -109,6 +109,22 @@ async function detectDatabase(ctx) {
|
|
|
109
109
|
evidence.push({ type: 'dependency', value: h });
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Elixir, by driver package.
|
|
114
|
+
*
|
|
115
|
+
* Ecto is the query layer and names no store: `postgrex` is what makes it Postgres,
|
|
116
|
+
* `ecto_ch` what makes it ClickHouse. plausible has both — its application data in
|
|
117
|
+
* Postgres and its analytics in ClickHouse — and was reported as having no data
|
|
118
|
+
* layer at all.
|
|
119
|
+
*/
|
|
120
|
+
for (const [db, names] of catalogue_1.ELIXIR_DATABASES) {
|
|
121
|
+
const hits = (0, detectContext_1.hasAnyElixirDep)(ctx, names);
|
|
122
|
+
if (hits.length) {
|
|
123
|
+
databases.add(db);
|
|
124
|
+
for (const h of hits)
|
|
125
|
+
evidence.push({ type: 'dependency', value: h });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
112
128
|
const goHits = [
|
|
113
129
|
['postgres', ['jackc/pgx', 'jackc/pgx/v5', 'lib/pq']],
|
|
114
130
|
['mysql', ['go-sql-driver/mysql']],
|
|
@@ -78,7 +78,7 @@ async function detectDeployment(ctx) {
|
|
|
78
78
|
key: 'deployment.readiness',
|
|
79
79
|
present: presentFiles.length > 0 || hits.length > 0,
|
|
80
80
|
complete: prodAware,
|
|
81
|
-
evidence: (0, absenceEvidence_1.
|
|
81
|
+
evidence: (0, absenceEvidence_1.evidenceOrFileNameSearch)(evidence, 'anything that says how this is deployed', ['Dockerfile', 'docker-compose', '.github/workflows/', 'Procfile', 'fly.toml', 'render.yaml', 'NODE_ENV', 'RAILS_ENV', 'ASPNETCORE_ENVIRONMENT', 'a file naming production']),
|
|
82
82
|
details: {
|
|
83
83
|
dockerArtifacts: presentFiles.some((f) => /Dockerfile|compose/.test(f)),
|
|
84
84
|
ci: presentFiles.some((f) => f.startsWith('.github/workflows/')),
|
|
@@ -38,7 +38,7 @@ async function detectDocker(ctx) {
|
|
|
38
38
|
key: 'infra.docker',
|
|
39
39
|
present: Boolean(dockerfile || composeFile),
|
|
40
40
|
complete: hasHealthcheck,
|
|
41
|
-
evidence: (0, absenceEvidence_1.
|
|
41
|
+
evidence: (0, absenceEvidence_1.evidenceOrFileNameSearch)(evidence, 'a container definition', ['Dockerfile', 'Containerfile', 'docker-compose.yml', 'compose.yaml']),
|
|
42
42
|
details: { dockerfile: Boolean(dockerfile), compose: Boolean(composeFile), hasHealthcheck, hasExpose, services },
|
|
43
43
|
};
|
|
44
44
|
}
|
|
@@ -30,9 +30,11 @@ const LOGGING_PACKAGES = [
|
|
|
30
30
|
'debug',
|
|
31
31
|
'npmlog',
|
|
32
32
|
];
|
|
33
|
+
/** Elixir reaches for a backend rather than a logger: Logger itself is in OTP. */
|
|
34
|
+
const ELIXIR_LOGGING_PACKAGES = ['logger_json', 'logger_file_backend', 'sentry'];
|
|
33
35
|
async function detectObservability(ctx) {
|
|
34
36
|
const evidence = [];
|
|
35
|
-
const logDeps = (0, detectContext_1.hasAnyDep)(ctx, LOGGING_PACKAGES);
|
|
37
|
+
const logDeps = [...(0, detectContext_1.hasAnyDep)(ctx, LOGGING_PACKAGES), ...(0, detectContext_1.hasAnyElixirDep)(ctx, ELIXIR_LOGGING_PACKAGES)];
|
|
36
38
|
const sentryDeps = (0, detectContext_1.hasAnyDep)(ctx, ['@sentry/node', 'sentry-sdk']);
|
|
37
39
|
for (const d of logDeps)
|
|
38
40
|
evidence.push({ type: 'dependency', value: d, claim: 'logging' });
|
|
@@ -100,6 +102,13 @@ async function detectObservability(ctx) {
|
|
|
100
102
|
/LoggerFactory\.getLogger|org\.slf4j/,
|
|
101
103
|
/Rails\.logger/,
|
|
102
104
|
/\btracing::(info|warn|error|debug)!|\blog::(info|warn|error)!/,
|
|
105
|
+
/**
|
|
106
|
+
* Elixir's, which is one module in the standard library.
|
|
107
|
+
*
|
|
108
|
+
* `Logger.info(...)` is how every Phoenix application logs, and `Logger` is
|
|
109
|
+
* OTP's name rather than anybody's variable.
|
|
110
|
+
*/
|
|
111
|
+
/\bLogger\.(info|warning|warn|error|debug|notice)\(/,
|
|
103
112
|
], 15);
|
|
104
113
|
for (const m of structuredHits)
|
|
105
114
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
|
|
@@ -140,7 +149,7 @@ async function detectObservability(ctx) {
|
|
|
140
149
|
evidence.push(...(0, absenceEvidence_1.searchedFor)('a health endpoint', ['/health', '/healthz', '/readyz', 'a health, healthz, readyz, liveness or readiness route file'], 'health'));
|
|
141
150
|
}
|
|
142
151
|
if (!hasAnyLogging) {
|
|
143
|
-
evidence.push(...(0, absenceEvidence_1.searchedFor)('logging', ['winston', 'pino', 'morgan', 'bunyan', 'Monolog', 'slog', 'zap', 'logrus', 'slf4j', 'Rails.logger', 'tracing::', 'logger.info/warn/error/debug', 'error_log(', 'JSON.stringify with a level field'], 'logging'));
|
|
152
|
+
evidence.push(...(0, absenceEvidence_1.searchedFor)('logging', ['winston', 'pino', 'morgan', 'bunyan', 'Monolog', 'slog', 'zap', 'logrus', 'slf4j', 'Rails.logger', 'tracing::', 'logger.info/warn/error/debug', 'Logger.info', 'error_log(', 'JSON.stringify with a level field'], 'logging'));
|
|
144
153
|
}
|
|
145
154
|
return {
|
|
146
155
|
key: 'observability.core',
|
|
@@ -18,6 +18,7 @@ const OTHER_MANIFESTS = [
|
|
|
18
18
|
{ manager: 'go modules', pattern: /(^|\/)go\.mod$/ },
|
|
19
19
|
{ manager: 'cargo', pattern: /(^|\/)Cargo\.toml$/ },
|
|
20
20
|
{ manager: 'bundler', pattern: /(^|\/)Gemfile$/ },
|
|
21
|
+
{ manager: 'mix', pattern: /(^|\/)mix\.exs$/ },
|
|
21
22
|
{ manager: 'gradle', pattern: /(^|\/)build\.gradle(\.kts)?$/ },
|
|
22
23
|
{ manager: 'maven', pattern: /(^|\/)pom\.xml$/ },
|
|
23
24
|
{ manager: 'nuget', pattern: /\.(csproj|fsproj|vbproj)$/i },
|
|
@@ -59,6 +60,7 @@ const MANIFEST_LANGUAGE = {
|
|
|
59
60
|
'go modules': /\.go$/,
|
|
60
61
|
cargo: /\.rs$/,
|
|
61
62
|
bundler: /\.rb$/,
|
|
63
|
+
mix: /\.exs?$/,
|
|
62
64
|
gradle: /\.(java|kt|kts|scala|groovy)$/,
|
|
63
65
|
maven: /\.(java|kt|kts|scala|groovy)$/,
|
|
64
66
|
nuget: /\.(cs|fs|vb)$/,
|
|
@@ -211,6 +211,23 @@ async function detectSecurity(ctx) {
|
|
|
211
211
|
/config\.content_security_policy\b/,
|
|
212
212
|
/^\s*config\.force_ssl\s*=\s*true/m,
|
|
213
213
|
], 20);
|
|
214
|
+
/**
|
|
215
|
+
* Reading a header is not setting one.
|
|
216
|
+
*
|
|
217
|
+
* plausible ships `tracker/installation_support/check-disallowed-by-csp.js`, whose
|
|
218
|
+
* whole job is to look at somebody else's `content-security-policy` and tell a user
|
|
219
|
+
* why the tracker was blocked. Its line `responseHeaders?.['content-security-policy']`
|
|
220
|
+
* was the evidence behind "security headers: passed" — a tool that inspects other
|
|
221
|
+
* people's headers credited with setting its own.
|
|
222
|
+
*
|
|
223
|
+
* The shape is the anchor, not the file: a header name used as a key into a headers
|
|
224
|
+
* object is a lookup. Setting one is a call — `put_resp_header`, `setHeader`,
|
|
225
|
+
* `headers.set`, `add_header` — or an assignment to that subscript, and a line doing
|
|
226
|
+
* either is left alone.
|
|
227
|
+
*/
|
|
228
|
+
const READS_A_HEADER = /\[\s*(['"`])[^'"`]+\1\s*\](?!\s*=[^=])/;
|
|
229
|
+
const SETS_A_HEADER = /put_resp_header|setHeader|set_header|add_header|headers\.(?:set|append)|writeHead/i;
|
|
230
|
+
const headerSignalsThatSet = headerSignals.filter((hit) => !READS_A_HEADER.test(hit.snippet) || SETS_A_HEADER.test(hit.snippet));
|
|
214
231
|
/**
|
|
215
232
|
* Spring Security, which writes the headers without being asked.
|
|
216
233
|
*
|
|
@@ -249,7 +266,7 @@ async function detectSecurity(ctx) {
|
|
|
249
266
|
claim: 'headers',
|
|
250
267
|
});
|
|
251
268
|
}
|
|
252
|
-
const helmet = helmetDep ||
|
|
269
|
+
const helmet = helmetDep || headerSignalsThatSet.length > 0 || springFilterChain.length > 0;
|
|
253
270
|
/**
|
|
254
271
|
* The packages the ecosystem names, as distinct from the variables authors do.
|
|
255
272
|
* Shared between the dependency check below and the binding walk further down.
|
|
@@ -279,7 +296,9 @@ async function detectSecurity(ctx) {
|
|
|
279
296
|
* that it does not throttle. The list had grown npm, Python and Rust entries and
|
|
280
297
|
* skipped the ecosystem where the answer is a single well-known name.
|
|
281
298
|
*/
|
|
282
|
-
(0, detectContext_1.hasAnyRubyDep)(ctx, ['rack-attack', 'rack_attack']).length > 0
|
|
299
|
+
(0, detectContext_1.hasAnyRubyDep)(ctx, ['rack-attack', 'rack_attack']).length > 0 ||
|
|
300
|
+
/** Elixir's, which are plugs: `hammer` counts, `plug_attack` and `ex_rated` refuse. */
|
|
301
|
+
(0, detectContext_1.hasAnyElixirDep)(ctx, ['hammer', 'plug_attack', 'ex_rated', 'pow_ratelimit']).length > 0;
|
|
283
302
|
/**
|
|
284
303
|
* Throttling by what the protocol says, not by what the variable is called.
|
|
285
304
|
*
|
|
@@ -404,7 +423,7 @@ async function detectSecurity(ctx) {
|
|
|
404
423
|
evidence.push({ type: 'dependency', value: 'helmet', claim: 'headers' });
|
|
405
424
|
if (rateLimitDep)
|
|
406
425
|
evidence.push({ type: 'dependency', value: 'rate limiting package', claim: 'rate-limit' });
|
|
407
|
-
for (const m of
|
|
426
|
+
for (const m of headerSignalsThatSet)
|
|
408
427
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'headers' });
|
|
409
428
|
for (const m of issuedRateLimits)
|
|
410
429
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'rate-limit' });
|
|
@@ -578,6 +597,33 @@ async function detectSecurity(ctx) {
|
|
|
578
597
|
const cited = anyOrigin[0] ?? chosen[0] ?? aspNetCors[0];
|
|
579
598
|
target.push({ file: cited.file, line: cited.line, snippet: cited.snippet.trim().slice(0, 200) });
|
|
580
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* Elixir's, which is a plug in the endpoint or the router.
|
|
602
|
+
*
|
|
603
|
+
* `cors_plug` and `corsica` are the two packages, and both are used the same way:
|
|
604
|
+
* `plug CORSPlug, origin: ["https://app.example.com"]` or `plug Corsica, origins:
|
|
605
|
+
* "*"`. The module name comes from the package, so it is the anchor; what the
|
|
606
|
+
* author chose is the value of `origin`.
|
|
607
|
+
*
|
|
608
|
+
* `plug` takes parentheses as readily as not — plausible writes `plug(CORSPlug)` in
|
|
609
|
+
* its endpoint, and a rule that required a space after the keyword could not see
|
|
610
|
+
* it. Both forms are ordinary Elixir and the formatter leaves either alone.
|
|
611
|
+
*
|
|
612
|
+
* `"*"` is the wide-open one. A list, a function or a regex is a decision somebody
|
|
613
|
+
* made, and the same reflection-versus-allowlist question the other frameworks are
|
|
614
|
+
* asked. Where the plug names no origin at all, cors_plug's own default is `"*"`,
|
|
615
|
+
* so silence is the open branch rather than the strict one.
|
|
616
|
+
*/
|
|
617
|
+
const elixirCorsPackages = (0, detectContext_1.hasAnyElixirDep)(ctx, ['cors_plug', 'corsica']);
|
|
618
|
+
if (elixirCorsPackages.length > 0) {
|
|
619
|
+
const elixirSource = source.filter((f) => /\.exs?$/i.test(f));
|
|
620
|
+
const plugged = await (0, textSearch_1.searchInFiles)(ctx.root, elixirSource, [/\bplug[\s(]+(?:CORSPlug|Corsica)\b/], 5);
|
|
621
|
+
for (const hit of plugged) {
|
|
622
|
+
const origin = /origins?:\s*(.+)$/.exec(hit.snippet);
|
|
623
|
+
const wideOpen = !origin || /^["']\*["']/.test(origin[1].trim());
|
|
624
|
+
(wideOpen ? corsLoose : corsStrict).push({ file: hit.file, line: hit.line, snippet: hit.snippet.trim().slice(0, 200) });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
581
627
|
const django = await (0, djangoSettings_1.findDjangoSettings)(ctx);
|
|
582
628
|
if (django) {
|
|
583
629
|
const middlewareLine = django.text
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The lines of a Python file that are
|
|
2
|
+
* The lines of a Python or Elixir file that are prose rather than code.
|
|
3
3
|
*
|
|
4
4
|
* This analyzer has skipped comments since the day it read its own prose about Stripe
|
|
5
5
|
* as evidence that it takes payments. The rule looks for a line marker — `#`, `//`,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.proseLines = proseLines;
|
|
4
4
|
/**
|
|
5
|
-
* The lines of a Python file that are
|
|
5
|
+
* The lines of a Python or Elixir file that are prose rather than code.
|
|
6
6
|
*
|
|
7
7
|
* This analyzer has skipped comments since the day it read its own prose about Stripe
|
|
8
8
|
* as evidence that it takes payments. The rule looks for a line marker — `#`, `//`,
|
|
@@ -20,6 +20,8 @@ exports.proseLines = proseLines;
|
|
|
20
20
|
* has nothing before it but indentation.
|
|
21
21
|
*/
|
|
22
22
|
function proseLines(file, text) {
|
|
23
|
+
if (/\.exs?$/i.test(file))
|
|
24
|
+
return elixirDocLines(text);
|
|
23
25
|
const prose = new Set();
|
|
24
26
|
if (!/\.py$/i.test(file))
|
|
25
27
|
return prose;
|
|
@@ -54,3 +56,37 @@ function proseLines(file, text) {
|
|
|
54
56
|
}
|
|
55
57
|
return prose;
|
|
56
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Elixir writes its prose as a module attribute, and the rest is a heredoc.
|
|
61
|
+
*
|
|
62
|
+
* `@moduledoc """ ... """` is the same trap as a Python docstring with a different
|
|
63
|
+
* marker: the interior lines begin with whatever the author was saying, and nothing
|
|
64
|
+
* about them says they are not code. Phoenix generators put one at the top of every
|
|
65
|
+
* controller, context and channel, so a repository has thousands.
|
|
66
|
+
*
|
|
67
|
+
* Anchored on Elixir's own attribute names — `@moduledoc`, `@doc`, `@typedoc`,
|
|
68
|
+
* `@shortdoc` — rather than on the heredoc, because `@query """SELECT ..."""` is data
|
|
69
|
+
* assigned to a name and a hardcoded secret could live in one. Same distinction the
|
|
70
|
+
* Python reader draws, made by a different marker.
|
|
71
|
+
*/
|
|
72
|
+
function elixirDocLines(text) {
|
|
73
|
+
const prose = new Set();
|
|
74
|
+
const lines = text.split(/\r?\n/);
|
|
75
|
+
let open = false;
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
if (open) {
|
|
79
|
+
prose.add(i + 1);
|
|
80
|
+
if (/"""/.test(line))
|
|
81
|
+
open = false;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const doc = /^\s*@(?:module|type|short)?doc\s+(?:~[A-Za-z])?"""/.exec(line);
|
|
85
|
+
if (!doc)
|
|
86
|
+
continue;
|
|
87
|
+
prose.add(i + 1);
|
|
88
|
+
/** A heredoc cannot close on its opening line, so the block is always open here. */
|
|
89
|
+
open = true;
|
|
90
|
+
}
|
|
91
|
+
return prose;
|
|
92
|
+
}
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Only managers whose manifest this analyzer actually reads are named here. A name it
|
|
11
11
|
* cannot back with a parsed file would be a guess from a filename.
|
|
12
12
|
*/
|
|
13
|
-
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'pub' | 'composer' | 'go modules' | 'cargo' | 'bundler' | 'gradle' | 'maven' | 'nuget' | 'swift package manager' | 'cocoapods' | 'unknown';
|
|
13
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'pip' | 'poetry' | 'python' | 'pub' | 'composer' | 'go modules' | 'cargo' | 'bundler' | 'gradle' | 'maven' | 'nuget' | 'swift package manager' | 'cocoapods' | 'mix' | 'unknown';
|
|
14
14
|
export type PackageManagerConfidence = 'lockfile' | 'manifest' | 'inferred' | 'unknown';
|
|
15
15
|
export interface WorkspaceStack {
|
|
16
16
|
root: string;
|
|
@@ -52,6 +52,15 @@ export interface DetectorEvidence {
|
|
|
52
52
|
* the reader can check and disagree with.
|
|
53
53
|
*/
|
|
54
54
|
type: 'file' | 'dependency' | 'snippet' | 'note' | 'search';
|
|
55
|
+
/**
|
|
56
|
+
* For a `search`: whether it read the source text or only the list of file names.
|
|
57
|
+
*
|
|
58
|
+
* "No Dockerfile" is true of a repository in any language, because the file list is
|
|
59
|
+
* readable whatever is inside the files. "No security headers" is a claim about what
|
|
60
|
+
* the code does, and a language this analyzer cannot read cannot answer it. Only the
|
|
61
|
+
* second kind is withdrawn where most of the repository went unread.
|
|
62
|
+
*/
|
|
63
|
+
overFileNames?: boolean;
|
|
55
64
|
value: string;
|
|
56
65
|
file?: string;
|
|
57
66
|
line?: number;
|
|
@@ -72,6 +72,33 @@ function onlyEvidencedInDocumentation(finding, hasProductSource) {
|
|
|
72
72
|
const cited = finding.evidence.filter((item) => item.file);
|
|
73
73
|
return cited.length > 0 && cited.every((item) => detectPackaging_1.DOCS_DIRECTORIES.test(item.file));
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* A verdict nothing could have reached.
|
|
77
|
+
*
|
|
78
|
+
* Teaching the analyzer to read `mix.exs` gave it plausible's backend, its two data
|
|
79
|
+
* stores and its password hashing — all from package names nobody there invented. It
|
|
80
|
+
* also turned every other capability from `unknown` into `missing`: no CORS, no
|
|
81
|
+
* security headers, no health endpoint, no consent, each at `high`, about 1257 Elixir
|
|
82
|
+
* files the analyzer had not opened. The guard that used to hold those back was an
|
|
83
|
+
* accident — with no backend detected, the rules said nothing here serves requests —
|
|
84
|
+
* and naming the backend removed it.
|
|
85
|
+
*
|
|
86
|
+
* So it is said properly. Where most of a repository is a language this analyzer does
|
|
87
|
+
* not read, a finding whose only evidence is the search that found nothing is a
|
|
88
|
+
* question that went unasked, and `unknown` is the answer. A finding still holding a
|
|
89
|
+
* dependency, a file or a line stands: `bcrypt_elixir` in the manifest is evidence
|
|
90
|
+
* whatever language the rest of the repository is in, and so is a search over the list
|
|
91
|
+
* of file names: "no Dockerfile" is true of a repository in any language, because the
|
|
92
|
+
* names are readable whatever is inside the files.
|
|
93
|
+
*
|
|
94
|
+
* This is the same rule as the documentation one above, for the same reason. Blindness
|
|
95
|
+
* may turn a verdict into no verdict; it may never turn it into the opposite verdict.
|
|
96
|
+
*/
|
|
97
|
+
function onlyEvidencedByASearchThatCouldNotRead(finding) {
|
|
98
|
+
if (finding.status === 'passed' || finding.status === 'unknown')
|
|
99
|
+
return false;
|
|
100
|
+
return finding.evidence.every((item) => item.type === 'search' && !item.overFileNames);
|
|
101
|
+
}
|
|
75
102
|
function buildReport(analysis, options) {
|
|
76
103
|
const observedFindings = (0, ruleEngine_1.runRules)(analysis);
|
|
77
104
|
const observedScore = (0, score_1.computeScore)(observedFindings);
|
|
@@ -138,15 +165,30 @@ function buildReport(analysis, options) {
|
|
|
138
165
|
* product and its findings are the product's.
|
|
139
166
|
*/
|
|
140
167
|
const hasProductSource = analysis.files.source.some((file) => !detectPackaging_1.DOCS_DIRECTORIES.test(file));
|
|
168
|
+
/**
|
|
169
|
+
* Read here as well as below, because it decides what the findings may claim and not
|
|
170
|
+
* only whether the report carries a score.
|
|
171
|
+
*/
|
|
172
|
+
const unreadableFileCount = analysis.files.unreadable.reduce((total, entry) => total + entry.files, 0);
|
|
173
|
+
const mostlyUnreadable = unreadableFileCount > analysis.files.source.length;
|
|
174
|
+
const [largestUnreadable] = [...analysis.files.unreadable].sort((left, right) => right.files - left.files);
|
|
141
175
|
const findings = (0, evidenceDigest_1.withEvidenceDigest)((0, businessImpact_1.withBusinessImpact)([...observedFindings, ...expectationFindings]
|
|
142
|
-
.map((finding) =>
|
|
176
|
+
.map((finding) => mostlyUnreadable && onlyEvidencedByASearchThatCouldNotRead(finding)
|
|
143
177
|
? {
|
|
144
178
|
...finding,
|
|
145
179
|
status: 'unknown',
|
|
146
180
|
severity: 'info',
|
|
147
|
-
description: `${finding.description}
|
|
181
|
+
description: `${finding.description} Most of this repository is written in ${largestUnreadable?.language ?? 'a language'}, which this analyzer does not read, so nothing here was in a position to answer.`,
|
|
182
|
+
recommendation: '',
|
|
148
183
|
}
|
|
149
|
-
: finding)
|
|
184
|
+
: onlyEvidencedInDocumentation(finding, hasProductSource)
|
|
185
|
+
? {
|
|
186
|
+
...finding,
|
|
187
|
+
status: 'unknown',
|
|
188
|
+
severity: 'info',
|
|
189
|
+
description: `${finding.description} Every line behind this is in the documentation site or a playground rather than in the product, so nothing here says the product does it.`,
|
|
190
|
+
}
|
|
191
|
+
: finding)
|
|
150
192
|
.sort((a, b) => bySeverityPriority(a) - bySeverityPriority(b))));
|
|
151
193
|
const combinedScore = expectationScore === undefined
|
|
152
194
|
? observedScore
|
|
@@ -241,8 +283,7 @@ function buildReport(analysis, options) {
|
|
|
241
283
|
* this was written on crosses the line, and the first public repository that did was
|
|
242
284
|
* the sixth one tried.
|
|
243
285
|
*/
|
|
244
|
-
const unreadableFiles =
|
|
245
|
-
const mostlyUnreadable = unreadableFiles > analysis.files.source.length;
|
|
286
|
+
const unreadableFiles = unreadableFileCount;
|
|
246
287
|
const inconclusive = nothingIdentified || tooLittleAssessed || mostlyUnreadable;
|
|
247
288
|
// Each reason says which of the two it was, because they call for different things:
|
|
248
289
|
// one is a repository this analyzer cannot read, the other is one there is barely
|
|
@@ -256,7 +297,7 @@ function buildReport(analysis, options) {
|
|
|
256
297
|
}
|
|
257
298
|
}
|
|
258
299
|
if (mostlyUnreadable) {
|
|
259
|
-
const
|
|
300
|
+
const largest = largestUnreadable;
|
|
260
301
|
inconclusiveReasons.push(`Most of this repository is written in ${largest.language}, which this analyzer does not read: ${unreadableFiles} of its files were skipped and ${analysis.files.source.length} were read.`);
|
|
261
302
|
}
|
|
262
303
|
if (tooLittleAssessed) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@produtype/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Deterministic CLI and library that analyzes a web application repository and reports how far it is from production-ready for the kind of product it is meant to be.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|