@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.
@@ -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, [/x-api-key/i, /apiKey/i, /API_KEY/, /token\s*scope/i], 20);
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: snippetEvidence(passwordResetSignals),
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: snippetEvidence(emailVerificationSignals),
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
- const keyFiles = ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yaml', 'Procfile', 'nginx.conf'];
8
- const presentFiles = ctx.files.all.filter((f) => keyFiles.some((k) => f.endsWith(k)) || f.startsWith('.github/workflows/'));
9
- for (const f of presentFiles)
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, [/SIGTERM/i, /SIGINT/i, /NODE_ENV/i, /DEBUG\s*=\s*False/, /healthcheck/i], 20);
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
- const prodAware = hits.some((h) => /NODE_ENV|DEBUG\s*=\s*False/.test(h.snippet));
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: toEvidence(consent),
43
+ evidence: evidenceOr(consent, 'consent'),
31
44
  },
32
45
  {
33
46
  key: 'gdpr.export.route',
34
47
  present: exportRoute.length > 0,
35
- evidence: toEvidence(exportRoute),
48
+ evidence: evidenceOr(exportRoute, 'export'),
36
49
  },
37
50
  {
38
51
  key: 'gdpr.erasure.route',
39
52
  present: erasure.length > 0,
40
- evidence: toEvidence(erasure),
53
+ evidence: evidenceOr(erasure, 'erasure'),
41
54
  },
42
55
  {
43
56
  key: 'gdpr.retention.job',
44
57
  present: retention.length > 0,
45
- evidence: toEvidence(retention),
58
+ evidence: evidenceOr(retention, 'retention'),
46
59
  },
47
60
  {
48
61
  key: 'gdpr.adminQueue',
49
62
  present: adminQueue.length > 0,
50
- evidence: toEvidence(adminQueue),
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, [/JSON\.stringify\(\s*\{[^}]*level/i, /logger\.(info|warn|error|debug)\s*\(/, /structuredLog/i], 15);
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: hasStructuredLogging || hits.length > 0 || healthFiles.length > 0,
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);
@@ -27,7 +27,19 @@ export interface StackInfo {
27
27
  workspaces: WorkspaceStack[];
28
28
  }
29
29
  export interface DetectorEvidence {
30
- type: 'file' | 'dependency' | 'snippet' | 'note';
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'>;