@produtype/core 0.10.2 → 0.17.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/dist/analyzer/absenceEvidence.d.ts +15 -0
- package/dist/analyzer/absenceEvidence.js +25 -0
- package/dist/analyzer/detectAuth.js +42 -3
- package/dist/analyzer/detectDeployment.js +67 -5
- package/dist/analyzer/detectGdpr.js +18 -5
- package/dist/analyzer/detectObservability.js +40 -2
- package/dist/analyzer/detectSecurity.js +17 -0
- package/dist/analyzer/types.d.ts +13 -1
- package/dist/expectations/evaluateExpectations.d.ts +15 -1
- package/dist/expectations/evaluateExpectations.js +163 -27
- package/dist/expectations/types.d.ts +8 -0
- package/dist/planner/remediationCatalog.js +557 -209
- package/dist/planner/types.d.ts +10 -0
- package/dist/report/buildReport.js +60 -8
- package/dist/report/businessImpact.d.ts +0 -1
- package/dist/report/businessImpact.js +28 -0
- package/dist/report/categoryScores.d.ts +4 -0
- package/dist/report/categoryScores.js +48 -2
- package/dist/report/evidenceDigest.d.ts +2 -0
- package/dist/report/evidenceDigest.js +80 -0
- package/dist/report/executiveSummary.js +80 -19
- package/dist/report/markdownReport.js +12 -4
- package/dist/report/score.d.ts +38 -1
- package/dist/report/score.js +50 -2
- package/dist/report/types.d.ts +10 -0
- package/dist/rules/ruleEngine.js +12 -2
- package/dist/rules/rules.js +181 -29
- package/dist/utils/textSearch.js +48 -0
- package/package.json +1 -1
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { DetectorEvidence } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* What was looked for, when nothing was found.
|
|
4
|
+
*
|
|
5
|
+
* A finding about something that is not there has no line to cite, and the report said
|
|
6
|
+
* so with "no direct evidence captured" — a sentence that reads like an admission of
|
|
7
|
+
* not having looked. It is the weakest thing in a report somebody paid for, and it is
|
|
8
|
+
* repeated: five findings in one GDPR section carried it in a row.
|
|
9
|
+
*
|
|
10
|
+
* The search itself is the evidence. A reader who sees "searched for an export
|
|
11
|
+
* endpoint: /gdpr/export, exportUserData, personalDataExport, dataSubject" can tell at
|
|
12
|
+
* a glance whether their own implementation would have been found, and say so when it
|
|
13
|
+
* would not. That is the difference between a claim and an assertion.
|
|
14
|
+
*/
|
|
15
|
+
export declare function searchedFor(what: string, terms: string[], claim?: string): DetectorEvidence[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.searchedFor = searchedFor;
|
|
4
|
+
/**
|
|
5
|
+
* What was looked for, when nothing was found.
|
|
6
|
+
*
|
|
7
|
+
* A finding about something that is not there has no line to cite, and the report said
|
|
8
|
+
* so with "no direct evidence captured" — a sentence that reads like an admission of
|
|
9
|
+
* not having looked. It is the weakest thing in a report somebody paid for, and it is
|
|
10
|
+
* repeated: five findings in one GDPR section carried it in a row.
|
|
11
|
+
*
|
|
12
|
+
* The search itself is the evidence. A reader who sees "searched for an export
|
|
13
|
+
* endpoint: /gdpr/export, exportUserData, personalDataExport, dataSubject" can tell at
|
|
14
|
+
* a glance whether their own implementation would have been found, and say so when it
|
|
15
|
+
* would not. That is the difference between a claim and an assertion.
|
|
16
|
+
*/
|
|
17
|
+
function searchedFor(what, terms, claim) {
|
|
18
|
+
if (terms.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
return [{
|
|
21
|
+
type: 'search',
|
|
22
|
+
value: `searched for ${what}: ${terms.join(', ')}`,
|
|
23
|
+
...(claim ? { claim } : {}),
|
|
24
|
+
}];
|
|
25
|
+
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectAuth = detectAuth;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
function depEvidence(deps) {
|
|
7
8
|
return deps.map((d) => ({ type: 'dependency', value: d }));
|
|
8
9
|
}
|
|
@@ -56,7 +57,38 @@ async function detectAuth(ctx) {
|
|
|
56
57
|
/\[\.\.\.nextauth\]/i,
|
|
57
58
|
], 30);
|
|
58
59
|
const twoFaSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [/two[_-]?factor/i, /otp/i, /totp/i], 20);
|
|
59
|
-
const apiKeySignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
60
|
+
const apiKeySignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
61
|
+
/**
|
|
62
|
+
* A key this project checks, not a key it holds.
|
|
63
|
+
*
|
|
64
|
+
* `apiKey` and `API_KEY` matched both, and almost every project that calls a
|
|
65
|
+
* model or a maps service has one of its own. `--api-key YOUR_API_KEY_HERE`, in
|
|
66
|
+
* the usage text of a script that downloads from YouTube, was enough to decide a
|
|
67
|
+
* one-page Streamlit application offers an API to other callers — and it was then
|
|
68
|
+
* asked at medium severity to restrict cross-origin access to it.
|
|
69
|
+
*
|
|
70
|
+
* What distinguishes a provider is reading a key out of an incoming request, or
|
|
71
|
+
* looking one up to see whether it is valid. Holding a secret is what a client
|
|
72
|
+
* does.
|
|
73
|
+
*/
|
|
74
|
+
/x-api-key/i,
|
|
75
|
+
/**
|
|
76
|
+
* `authorization` on its own is how every session and bearer-token guard reads
|
|
77
|
+
* its header: `if (!req.headers.authorization) return res.status(401)` is
|
|
78
|
+
* authentication, not API keys, and matching it made a hardened Express fixture
|
|
79
|
+
* claim an API-key scheme it does not have.
|
|
80
|
+
*/
|
|
81
|
+
/headers?\s*[[.(]\s*['"]?x-api-key/i,
|
|
82
|
+
/**
|
|
83
|
+
* A verb on its own does not say which side you are on. `check_api_key(api_key)`
|
|
84
|
+
* in a script that downloads from YouTube is a client making sure its own key
|
|
85
|
+
* looks right before spending a request on it. What it cannot be is a store of
|
|
86
|
+
* keys you issued.
|
|
87
|
+
*/
|
|
88
|
+
/api[_-]?keys?\s*\.\s*(find|where|get|create)/i,
|
|
89
|
+
/hashed?[_-]?(api[_-]?)?key/i,
|
|
90
|
+
/token\s*scope/i,
|
|
91
|
+
], 20);
|
|
60
92
|
const passwordResetSignals = await (0, textSearch_1.searchInFiles)(ctx.root, sourceFiles, [
|
|
61
93
|
/forgot\s*password/i,
|
|
62
94
|
/password[_-]?reset/i,
|
|
@@ -194,12 +226,19 @@ async function detectAuth(ctx) {
|
|
|
194
226
|
{
|
|
195
227
|
key: 'auth.passwordReset',
|
|
196
228
|
present: passwordResetSignals.length > 0,
|
|
197
|
-
evidence
|
|
229
|
+
// The terms, where nothing matched. "No direct evidence captured" reads like
|
|
230
|
+
// "we did not look"; this lets a reader whose flow is called `recoverAccess`
|
|
231
|
+
// see in one line why it was missed.
|
|
232
|
+
evidence: passwordResetSignals.length > 0
|
|
233
|
+
? snippetEvidence(passwordResetSignals)
|
|
234
|
+
: (0, absenceEvidence_1.searchedFor)('a password reset flow', ['"forgot password"', 'password_reset', 'password-reset', '"reset token"', 'django.contrib.auth.urls', 'PasswordResetView', 'devise_for', 'Auth::routes(']),
|
|
198
235
|
},
|
|
199
236
|
{
|
|
200
237
|
key: 'auth.emailVerification',
|
|
201
238
|
present: emailVerificationSignals.length > 0,
|
|
202
|
-
evidence:
|
|
239
|
+
evidence: emailVerificationSignals.length > 0
|
|
240
|
+
? snippetEvidence(emailVerificationSignals)
|
|
241
|
+
: (0, absenceEvidence_1.searchedFor)('email verification', ['"verify email"', 'email_verification', 'email-verification', '"confirm email"', 'isEmailVerified']),
|
|
203
242
|
},
|
|
204
243
|
{
|
|
205
244
|
key: 'auth.sessionStrategy',
|
|
@@ -4,15 +4,75 @@ exports.detectDeployment = detectDeployment;
|
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
5
|
async function detectDeployment(ctx) {
|
|
6
6
|
const evidence = [];
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* What a project ships to say how it is run.
|
|
9
|
+
*
|
|
10
|
+
* The list held six names and missed the one that mattered on a real product:
|
|
11
|
+
* `docker-compose.prod.yml`, a production override with its usage documented in the
|
|
12
|
+
* first three lines. Continuous integration outside GitHub was invisible too, and so
|
|
13
|
+
* was every platform descriptor.
|
|
14
|
+
*/
|
|
15
|
+
const keyFiles = [
|
|
16
|
+
'Dockerfile', 'Containerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
|
|
17
|
+
'Procfile', 'nginx.conf', 'fly.toml', 'render.yaml', 'railway.json', 'vercel.json', 'netlify.toml',
|
|
18
|
+
'app.yaml', 'Makefile', 'skaffold.yaml', 'Chart.yaml',
|
|
19
|
+
/**
|
|
20
|
+
* How a phone application ships. Its deployment story is a store pipeline, not a
|
|
21
|
+
* container: `Fastfile`, a Codemagic configuration, signing material and an export
|
|
22
|
+
* options plist are the artifacts that decide whether a release is reproducible.
|
|
23
|
+
*/
|
|
24
|
+
'codemagic.yaml', 'Fastfile', 'Appfile', 'ExportOptions.plist', 'key.properties',
|
|
25
|
+
];
|
|
26
|
+
const presentFiles = ctx.files.all.filter((f) => keyFiles.some((k) => f.endsWith(k))
|
|
27
|
+
// A compose override, whatever it is called: docker-compose.prod.yml, compose.staging.yaml.
|
|
28
|
+
|| /(^|\/)(docker-)?compose\.[\w.-]+\.ya?ml$/i.test(f)
|
|
29
|
+
|| f.startsWith('.github/workflows/')
|
|
30
|
+
|| /(^|\/)(\.gitlab-ci\.yml|\.circleci\/config\.yml|azure-pipelines\.yml|Jenkinsfile|\.woodpecker\.ya?ml)$/i.test(f)
|
|
31
|
+
|| /(^|\/)(k8s|kubernetes|helm|deploy|\.platform)\//i.test(f));
|
|
32
|
+
for (const f of presentFiles.slice(0, 20))
|
|
10
33
|
evidence.push({ type: 'file', value: f });
|
|
11
|
-
const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
|
|
34
|
+
const hits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
|
|
35
|
+
/SIGTERM/i, /SIGINT/i,
|
|
36
|
+
/**
|
|
37
|
+
* How a project knows which environment it is in.
|
|
38
|
+
*
|
|
39
|
+
* This was `NODE_ENV` and `DEBUG = False` — Node and Django — so a PHP product
|
|
40
|
+
* reading `APP_ENV` and shipping a production compose override was reported as
|
|
41
|
+
* having no production-aware configuration at all. Every stack has a name for
|
|
42
|
+
* this and none of them is NODE_ENV.
|
|
43
|
+
*
|
|
44
|
+
* `DJANGO_SETTINGS_MODULE` is deliberately absent: `os.environ.setdefault` of it
|
|
45
|
+
* sits in every `manage.py` and `wsgi.py` Django has ever generated, so counting
|
|
46
|
+
* it made a project with no Docker, no CI and `DEBUG = True` report a complete
|
|
47
|
+
* deployment story on the strength of its own boilerplate.
|
|
48
|
+
*/
|
|
49
|
+
/\b(NODE_ENV|APP_ENV|RAILS_ENV|RACK_ENV|ASPNETCORE_ENVIRONMENT|FLASK_ENV|GIN_MODE|ENVIRONMENT|PHP_ENV|ENV_NAME|DEPLOY_ENV)\b/,
|
|
50
|
+
/DEBUG\s*=\s*False/,
|
|
51
|
+
/healthcheck/i,
|
|
52
|
+
], 20);
|
|
12
53
|
for (const m of hits)
|
|
13
54
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line });
|
|
55
|
+
/**
|
|
56
|
+
* Graceful shutdown is a question about a long-lived process.
|
|
57
|
+
*
|
|
58
|
+
* PHP-FPM and CGI hand each request to a worker that exits when it is done; there is
|
|
59
|
+
* no signal for the application to catch, and asking for one is a Node and Go idiom
|
|
60
|
+
* pointed at a process model that does not have it.
|
|
61
|
+
*
|
|
62
|
+
* Decided on the presence of PHP alone: a PHP product's `public/js` does not change
|
|
63
|
+
* how its requests are served, and requiring the absence of JavaScript meant no real
|
|
64
|
+
* PHP application ever qualified.
|
|
65
|
+
*/
|
|
66
|
+
/** PHP-FPM hands each request to a worker that exits when it is done. */
|
|
67
|
+
const perRequestRuntime = ctx.files.source.some((f) => f.endsWith('.php'));
|
|
14
68
|
const graceful = hits.some((h) => /SIGTERM|SIGINT/i.test(h.snippet));
|
|
15
|
-
|
|
69
|
+
/**
|
|
70
|
+
* A file that exists only to describe production is production awareness, whatever
|
|
71
|
+
* the source says. It is also the more reliable signal of the two.
|
|
72
|
+
*/
|
|
73
|
+
const productionDescriptor = presentFiles.some((f) => /prod|production/i.test(f));
|
|
74
|
+
const prodAware = productionDescriptor
|
|
75
|
+
|| hits.some((h) => /\b(NODE_ENV|APP_ENV|RAILS_ENV|RACK_ENV|ASPNETCORE_ENVIRONMENT|FLASK_ENV|GIN_MODE|ENVIRONMENT|PHP_ENV|ENV_NAME|DEPLOY_ENV)\b/.test(h.snippet) || /DEBUG\s*=\s*False/.test(h.snippet));
|
|
16
76
|
return {
|
|
17
77
|
key: 'deployment.readiness',
|
|
18
78
|
present: presentFiles.length > 0 || hits.length > 0,
|
|
@@ -22,6 +82,8 @@ async function detectDeployment(ctx) {
|
|
|
22
82
|
dockerArtifacts: presentFiles.some((f) => /Dockerfile|compose/.test(f)),
|
|
23
83
|
ci: presentFiles.some((f) => f.startsWith('.github/workflows/')),
|
|
24
84
|
gracefulShutdown: graceful,
|
|
85
|
+
/** False where the runtime hands each request to a worker that exits on its own. */
|
|
86
|
+
gracefulShutdownApplies: !perRequestRuntime,
|
|
25
87
|
productionAware: prodAware,
|
|
26
88
|
},
|
|
27
89
|
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.detectGdpr = detectGdpr;
|
|
4
4
|
const textSearch_1 = require("../utils/textSearch");
|
|
5
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
5
6
|
function toEvidence(matches) {
|
|
6
7
|
return matches.map((m) => ({ type: 'snippet', value: m.snippet, file: m.file, line: m.line }));
|
|
7
8
|
}
|
|
@@ -23,31 +24,43 @@ async function detectGdpr(ctx) {
|
|
|
23
24
|
/delete personal data older than/i,
|
|
24
25
|
], 20);
|
|
25
26
|
const adminQueue = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/privacy.*queue/i, /gdpr.*queue/i, /data subject request/i, /dsar/i, /admin.*privacy/i], 20);
|
|
27
|
+
/**
|
|
28
|
+
* Written out once so the evidence for an absence cannot drift from the search that
|
|
29
|
+
* produced it: these are the same terms the patterns above match.
|
|
30
|
+
*/
|
|
31
|
+
const TERMS = {
|
|
32
|
+
consent: ['a consent record', ['cookie-consent', 'consentGiven', 'privacyConsent', 'gdprConsent']],
|
|
33
|
+
export: ['an export of personal data', ['/gdpr/export', 'exportUserData', 'personalDataExport', 'dataSubject', 'rightToAccess', '"data portability"']],
|
|
34
|
+
erasure: ['an erasure flow', ['erasure', '"delete account"', '"right to be forgotten"', '"delete user data"', '"delete personal data"']],
|
|
35
|
+
retention: ['a retention or purge policy', ['gdpr/privacy near retention/purge/delete', 'retention/purge/delete near personal data', '"delete personal data older than"']],
|
|
36
|
+
adminQueue: ['a data-subject request queue', ['privacy queue', 'gdpr queue', '"data subject request"', 'dsar', 'admin privacy']],
|
|
37
|
+
};
|
|
38
|
+
const evidenceOr = (matches, key) => (matches.length > 0 ? toEvidence(matches) : (0, absenceEvidence_1.searchedFor)(...TERMS[key]));
|
|
26
39
|
return [
|
|
27
40
|
{
|
|
28
41
|
key: 'gdpr.consent.route',
|
|
29
42
|
present: consent.length > 0,
|
|
30
|
-
evidence:
|
|
43
|
+
evidence: evidenceOr(consent, 'consent'),
|
|
31
44
|
},
|
|
32
45
|
{
|
|
33
46
|
key: 'gdpr.export.route',
|
|
34
47
|
present: exportRoute.length > 0,
|
|
35
|
-
evidence:
|
|
48
|
+
evidence: evidenceOr(exportRoute, 'export'),
|
|
36
49
|
},
|
|
37
50
|
{
|
|
38
51
|
key: 'gdpr.erasure.route',
|
|
39
52
|
present: erasure.length > 0,
|
|
40
|
-
evidence:
|
|
53
|
+
evidence: evidenceOr(erasure, 'erasure'),
|
|
41
54
|
},
|
|
42
55
|
{
|
|
43
56
|
key: 'gdpr.retention.job',
|
|
44
57
|
present: retention.length > 0,
|
|
45
|
-
evidence:
|
|
58
|
+
evidence: evidenceOr(retention, 'retention'),
|
|
46
59
|
},
|
|
47
60
|
{
|
|
48
61
|
key: 'gdpr.adminQueue',
|
|
49
62
|
present: adminQueue.length > 0,
|
|
50
|
-
evidence:
|
|
63
|
+
evidence: evidenceOr(adminQueue, 'adminQueue'),
|
|
51
64
|
},
|
|
52
65
|
];
|
|
53
66
|
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.detectObservability = detectObservability;
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const textSearch_1 = require("../utils/textSearch");
|
|
6
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
6
7
|
async function detectObservability(ctx) {
|
|
7
8
|
const evidence = [];
|
|
8
9
|
const logDeps = (0, detectContext_1.hasAnyDep)(ctx, ['winston', 'pino', 'morgan', 'bunyan']);
|
|
@@ -38,17 +39,54 @@ async function detectObservability(ctx) {
|
|
|
38
39
|
// Structured logging without a logging library is still structured logging. What
|
|
39
40
|
// matters is that entries are machine-readable and correlated, not which package
|
|
40
41
|
// produced them.
|
|
41
|
-
const structuredHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
|
|
42
|
+
const structuredHits = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [
|
|
43
|
+
/JSON\.stringify\(\s*\{[^}]*level/i,
|
|
44
|
+
/logger\.(info|warn|error|debug)\s*\(/,
|
|
45
|
+
/structuredLog/i,
|
|
46
|
+
/**
|
|
47
|
+
* The same idea in the languages this was blind to.
|
|
48
|
+
*
|
|
49
|
+
* It looked for `logger.info(` and a JSON.stringify with a level field, which are
|
|
50
|
+
* JavaScript and Python idioms, and reported "no structured logging dependency
|
|
51
|
+
* detected" about a PHP application whose global exception handler logs every
|
|
52
|
+
* exception. Go, Java, Ruby and Rust were invisible for the same reason.
|
|
53
|
+
*/
|
|
54
|
+
/Monolog\\Logger|LoggerInterface|->(info|warning|error|debug)\(/,
|
|
55
|
+
/\bslog\.(Info|Warn|Error|Debug)\(|\bzap\.|\blogrus\.|\blog\.Printf\(/,
|
|
56
|
+
/LoggerFactory\.getLogger|org\.slf4j/,
|
|
57
|
+
/Rails\.logger/,
|
|
58
|
+
/\btracing::(info|warn|error|debug)!|\blog::(info|warn|error)!/,
|
|
59
|
+
], 15);
|
|
42
60
|
for (const m of structuredHits)
|
|
43
61
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
|
|
62
|
+
/**
|
|
63
|
+
* Logging at all, as distinct from logging something a machine can read.
|
|
64
|
+
*
|
|
65
|
+
* `error_log($e)` is a real answer to "will we know this happened", and a different
|
|
66
|
+
* answer from Monolog with a request id. Reporting the first as nothing to show made
|
|
67
|
+
* the finding wrong; reporting it as structured would make the recommendation wrong.
|
|
68
|
+
* It is `partial`, and now it can be said.
|
|
69
|
+
*/
|
|
70
|
+
const plainLogging = await (0, textSearch_1.searchInFiles)(ctx.root, ctx.files.source, [/\berror_log\s*\(/, /\bsyslog\s*\(/, /console\.(error|warn)\s*\(/, /\bprintStackTrace\s*\(/], 10);
|
|
71
|
+
for (const m of plainLogging)
|
|
72
|
+
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'logging' });
|
|
44
73
|
const hasStructuredLogging = logDeps.length > 0 || structuredHits.length > 0;
|
|
74
|
+
const hasAnyLogging = hasStructuredLogging || plainLogging.length > 0;
|
|
75
|
+
if (!hasHealth) {
|
|
76
|
+
evidence.push(...(0, absenceEvidence_1.searchedFor)('a health endpoint', ['/health', '/healthz', '/readyz', 'a health, healthz, readyz, liveness or readiness route file'], 'health'));
|
|
77
|
+
}
|
|
78
|
+
if (!hasAnyLogging) {
|
|
79
|
+
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'));
|
|
80
|
+
}
|
|
45
81
|
return {
|
|
46
82
|
key: 'observability.core',
|
|
47
|
-
present:
|
|
83
|
+
present: hasAnyLogging || hits.length > 0 || healthFiles.length > 0,
|
|
48
84
|
complete: hasHealth && hasStructuredLogging,
|
|
49
85
|
evidence,
|
|
50
86
|
details: {
|
|
51
87
|
structuredLogging: hasStructuredLogging,
|
|
88
|
+
/** Any logging at all, structured or not. */
|
|
89
|
+
anyLogging: hasAnyLogging,
|
|
52
90
|
healthEndpoint: hasHealth,
|
|
53
91
|
requestId: hasReqId,
|
|
54
92
|
sentry: sentryDeps.length > 0,
|
|
@@ -4,6 +4,7 @@ exports.detectSecurity = detectSecurity;
|
|
|
4
4
|
const detectContext_1 = require("./detectContext");
|
|
5
5
|
const readTextFileSafe_1 = require("../utils/readTextFileSafe");
|
|
6
6
|
const textSearch_1 = require("../utils/textSearch");
|
|
7
|
+
const absenceEvidence_1 = require("./absenceEvidence");
|
|
7
8
|
function detectCorsConfig(text, file) {
|
|
8
9
|
const loose = [];
|
|
9
10
|
const strict = [];
|
|
@@ -99,6 +100,22 @@ async function detectSecurity(ctx) {
|
|
|
99
100
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
|
|
100
101
|
for (const m of corsStrict)
|
|
101
102
|
evidence.push({ type: 'snippet', value: m.snippet, file: m.file, line: m.line, claim: 'cors' });
|
|
103
|
+
/**
|
|
104
|
+
* What was looked for, where nothing was found.
|
|
105
|
+
*
|
|
106
|
+
* "No direct evidence captured" reads like "we did not look". These are the same
|
|
107
|
+
* terms the searches above use, so a reader whose rate limiter is a decorator called
|
|
108
|
+
* `@throttle` can see in one line why it was missed, and say so.
|
|
109
|
+
*/
|
|
110
|
+
if (!helmet) {
|
|
111
|
+
evidence.push(...(0, absenceEvidence_1.searchedFor)('security headers', ['helmet', 'django-csp', 'Content-Security-Policy', 'Strict-Transport-Security', 'X-Content-Type-Options', 'X-Frame-Options', 'SECURE_HSTS_SECONDS', 'securityHeaders'], 'headers'));
|
|
112
|
+
}
|
|
113
|
+
if (!rateLimit) {
|
|
114
|
+
evidence.push(...(0, absenceEvidence_1.searchedFor)('rate limiting', ['express-rate-limit', '@upstash/ratelimit', 'rate-limiter-flexible', 'django-ratelimit', 'slowapi', 'rateLimit(', 'rate_limit', 'Retry-After', '429', 'TooManyRequests'], 'rate-limit'));
|
|
115
|
+
}
|
|
116
|
+
if (corsLoose.length === 0 && corsStrict.length === 0) {
|
|
117
|
+
evidence.push(...(0, absenceEvidence_1.searchedFor)('cross-origin configuration', ['cors(', 'Access-Control-Allow-Origin', 'ALLOWED_ORIGINS', 'allowedOrigins'], 'cors'));
|
|
118
|
+
}
|
|
102
119
|
const webhookSig = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/constructEvent\(/, /webhook.*signature/i], 10);
|
|
103
120
|
const bodyLimit = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/express\.json\(\s*\{[^}]*limit\s*:/i], 10);
|
|
104
121
|
const contentTypeCheck = await (0, textSearch_1.searchInFiles)(ctx.root, source, [/content-type/i, /req\.is\(/], 10);
|
package/dist/analyzer/types.d.ts
CHANGED
|
@@ -27,7 +27,19 @@ export interface StackInfo {
|
|
|
27
27
|
workspaces: WorkspaceStack[];
|
|
28
28
|
}
|
|
29
29
|
export interface DetectorEvidence {
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* `search` records a look that came back empty.
|
|
32
|
+
*
|
|
33
|
+
* A report about a missing capability has nothing to point at, so eleven findings of
|
|
34
|
+
* thirty-two in a real report carried the line "no direct evidence captured" — which
|
|
35
|
+
* reads like "we did not look" rather than "we looked and it is not there". The
|
|
36
|
+
* reader cannot tell the difference, and an independent review said so.
|
|
37
|
+
*
|
|
38
|
+
* The evidence for an absence is the search that found nothing, and the detectors
|
|
39
|
+
* already know what they searched for. Saying it turns an assertion into something
|
|
40
|
+
* the reader can check and disagree with.
|
|
41
|
+
*/
|
|
42
|
+
type: 'file' | 'dependency' | 'snippet' | 'note' | 'search';
|
|
31
43
|
value: string;
|
|
32
44
|
file?: string;
|
|
33
45
|
line?: number;
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import type { ProjectAnalysis } from '../analyzer/types';
|
|
2
|
-
import type { DeclaredIntent, ExpectationEvaluationOutput, ProductProfile } from './types';
|
|
2
|
+
import type { CapabilityImportance, DeclaredIntent, ExpectationEvaluationOutput, ExpectedCapability, ProductProfile } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Nine of the twelve used to hardcode their suffix.
|
|
5
|
+
*
|
|
6
|
+
* `security.headers` said `.required` even where a profile asks for it as a
|
|
7
|
+
* recommendation, `deployment.docker` said `.recommended` even where it is required,
|
|
8
|
+
* and `auth.api-keys` produced a finding titled "(required)" at high severity whose id
|
|
9
|
+
* read `expectation.auth.api-keys.recommended`. A reader sees both lines, three apart,
|
|
10
|
+
* and they disagree.
|
|
11
|
+
*
|
|
12
|
+
* The suffix is part of the key, so it cannot simply be dropped; it can be made true.
|
|
13
|
+
* `getRemediationEntry` already matches across the two suffixes — it was written for
|
|
14
|
+
* exactly this — so the plan still finds its task.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toFindingId(capability: ExpectedCapability, importance: CapabilityImportance): string;
|
|
3
17
|
export declare function evaluateExpectedCapabilities(args: {
|
|
4
18
|
analysis: ProjectAnalysis;
|
|
5
19
|
selectedProfile: Exclude<ProductProfile, 'auto' | 'observed-only'>;
|