Seed security knowledge corpus (16 articles + 30 AL samples)

Converts Jesper's existing AL security-review prompt into BCQuality seed
knowledge articles so the al-security-review leaf has a real corpus to
match against. Mirrors the performance seed phase.

Articles under microsoft/knowledge/security/ (16):
- Permission model: follow-least-privilege-in-permission-sets,
  use-indirect-permissions-for-elevated-access,
  use-inherent-permissions-to-grant-minimal-access
- Secrets: never-hardcode-secrets-in-al,
  use-isolated-storage-for-module-and-company-secrets,
  prefer-azure-key-vault-for-production-secrets,
  use-secrettext-for-credentials, use-secrettext-with-httpclient,
  compose-secrets-with-secretstrsubstno,
  use-nondebuggable-when-parsing-secrets
- External calls: require-https-for-external-calls,
  set-timeouts-for-external-calls, do-not-put-credentials-in-urls
- Error handling: avoid-sensitive-data-in-error-messages,
  do-not-swallow-security-errors-silently
- Extensibility: do-not-expose-sensitive-data-in-event-publishers

Paired AL samples under samples/security/<slug>/{bad,good}.al, object
IDs 50200-50231 (no overlap with performance 50100-50140).

Rubber-duck findings addressed:
- HttpClient secret-URI: SetSecretRequestUri is on HttpRequestMessage
  (not HttpClient). Rewrote use-secrettext-with-httpclient and its
  good sample to use HttpRequestMessage + HttpClient.Send.
- InherentPermissions only grants access to same-extension objects;
  the sample now defines its own table 50230 "Sec Sample Lookup" and
  grants 'r' on that, not on Database::Customer.
- Reworked compose-secrets-with-secretstrsubstno bad.al away from
  Format(SecretText) (unreliable) to a plain Text+StrSubstNo anti-
  pattern.
- Moved normative guidance out of Description in three articles
  (compose-secrets-..., prefer-azure-key-vault-..., use-inherent-...)
  so it sits in Best Practice / Anti Pattern per READ contract.
- Added a companion helper codeunit (50231) to the indirect-permissions
  good sample so it actually demonstrates the controlled write path.
- Rebuilt the event-publisher good/bad pair on the same ExportCustomer
  scenario so the contrast is the shape of the event signature, not a
  different event.

Also: broaden samples/README.md object-ID range note to 50100-50299.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jesper Schulz-Wedde 2026-04-17 13:39:50 +02:00
parent 32c40bbf1d
commit 0980397d27
47 changed files with 899 additions and 1 deletions

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [error, disclosure, logging, label]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Avoid sensitive data in error messages
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Errors surfaced to end users are routinely forwarded to support systems, captured in bug reports, and exported to telemetry. Server names, database names, usernames, connection strings, file paths, and stack excerpts in an end-user error message leak infrastructure detail to untrusted consumers and help an attacker map the environment.
## Best Practice
Raise end-user errors using localized Labels that describe the condition without naming infrastructure. Emit the actual detail (exception text, endpoint, correlation id) through the application's internal logging channel, where audience and retention are controlled.
See sample: `samples/security/avoid-sensitive-data-in-error-messages/good.al`.
## Anti Pattern
Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=admin: %1', Ex.Message); — every support ticket now carries the server name, database name, and service account.
See sample: `samples/security/avoid-sensitive-data-in-error-messages/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [secretstrsubstno, secrettext, composition]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Compose secrets with SecretStrSubstNo
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
SecretStrSubstNo is the SecretText analogue of StrSubstNo. The template is a regular string literal; substitution arguments may be SecretText; the return value is SecretText. Intermediate results of the composition are never materialized as plaintext.
## Best Practice
Format SecretText templates with SecretStrSubstNo. This is the correct primitive for building authorization headers, secret URIs, and any other formatted string that embeds a SecretText. Provide the static parts of the template as a regular string literal; only the substitutions carry the secret value.
See sample: `samples/security/compose-secrets-with-secretstrsubstno/good.al`.
## Anti Pattern
Using StrSubstNo (or plain string concatenation) on a plain-Text token to build an authorization header. The result is a Text containing the secret in plaintext, visible in the debugger, inspectable in snapshot debug sessions, and captured by any logging the caller does not control. SecretText should have been used end-to-end.
See sample: `samples/security/compose-secrets-with-secretstrsubstno/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [event, publisher, extensibility, var-parameter]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not expose sensitive data in event publishers
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Events in AL are extensibility contracts. Every subscriber — third-party, internal, or installed after the fact — receives the full set of event parameters. Parameters that carry secrets, pre-authorization state, or variables the publisher relies on for access control effectively become public, and var-parameters can be mutated by a subscriber to alter publisher behaviour.
## Best Practice
Design event signatures to carry only the data a subscriber legitimately needs. Do not pass SecretText, credential material, or flags the publisher depends on for access control. If a subscriber needs to veto an action, model it as a separate OnBefore event whose Handled pattern is documented — not as a general-purpose var Boolean callers can flip.
See sample: `samples/security/do-not-expose-sensitive-data-in-event-publishers/good.al`.
## Anti Pattern
An OnBeforeElevateAccess publisher that exposes `var CanAccess: Boolean` — any subscriber installed on the tenant can flip it to true and escalate. Or a publisher that passes a SecretText parameter it obtained internally, handing it to every subscriber.
See sample: `samples/security/do-not-expose-sensitive-data-in-event-publishers/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [url, query-string, credentials, logging]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not put credentials in URLs
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
URL query strings and path segments are routinely captured in web-server access logs, browser history, proxy logs, platform telemetry, and exception traces. A credential placed anywhere in the URL therefore persists across systems the extension does not control, and is typically retained far longer than the secret's intended lifetime.
## Best Practice
Transport credentials in Authorization headers, carried as SecretText end-to-end (see use-secrettext-with-httpclient). Where the URI itself must carry a secret (for example, a pre-signed URL), build it with SecretStrSubstNo and pass it via SetSecretRequestUri so it is never materialized as Text.
See sample: `samples/security/do-not-put-credentials-in-urls/good.al`.
## Anti Pattern
Appending '?api_key=' + Key to a request URL, or embedding a token in a path segment, then calling HttpClient.Get with the resulting Text URL.
See sample: `samples/security/do-not-put-credentials-in-urls/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [tryfunction, logging, audit, error]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Do not swallow security errors silently
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Authentication failures, permission denials, and unexpected error paths in security-relevant code are the signals a reviewer or incident responder needs to see. A TryFunction whose failure is ignored without logging turns an attack or a misconfiguration into silent bad behaviour: the call returns false, the caller moves on, and no record of the event survives.
## Best Practice
Use TryFunctions to contain errors around security-relevant work, but always log the failure (category, GetLastErrorText, and enough context to identify the operation) before deciding whether to surface a user-facing error. Never discard a caught security error without a trace.
See sample: `samples/security/do-not-swallow-security-errors-silently/good.al`.
## Anti Pattern
`if not TryAuthenticate() then exit;` with no logging and no user-facing error. An authentication-bypass attempt, a revoked credential, and a transient network glitch are now indistinguishable.
See sample: `samples/security/do-not-swallow-security-errors-silently/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [permissionset, least-privilege, rimd, tabledata]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Follow least privilege in permission sets
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Permission sets define the tabledata and object rights granted to every user or role assigned to them. A permission set that grants RIMD on tabledata * hands every caller full control over every table the extension exposes, which is never the shape of access any real role requires. Over-broad permission sets are a persistent source of privilege-escalation risk: once assigned, they are rarely audited.
## Best Practice
Enumerate the specific tabledata objects a role needs and grant only the letters (R, I, M, D) that role genuinely uses. A sales order-entry role typically needs RIM on Sales Header, RIMD on Sales Line, and R on Customer — not blanket RIMD. Permission sets SHOULD be granular and role-shaped; a single permission set that covers every role in an extension is a design smell.
See sample: `samples/security/follow-least-privilege-in-permission-sets/good.al`.
## Anti Pattern
Granting `tabledata * = RIMD` (or any wildcard with I, M, or D) in a permission set. This bypasses any meaningful separation of duties the extension could enforce and gives unreviewed code paths the ability to insert, modify, and delete on any table.
See sample: `samples/security/follow-least-privilege-in-permission-sets/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [secrets, credentials, hardcoded, label, apikey]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Never hardcode secrets in AL
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
A secret embedded in AL source — API key, password, connection string, token — lives forever: in the app package, in source control history, in every debugger session that sees the assignment, and in any log that captures the containing variable. Rotation is effectively impossible without a new release, and the blast radius covers every tenant the extension is installed in.
## Best Practice
Retrieve secrets at runtime from a protected store: Azure Key Vault for production workloads (see prefer-azure-key-vault-for-production-secrets) or IsolatedStorage for tenant-local encrypted values (see use-isolated-storage-for-module-and-company-secrets). Carry the retrieved value in a SecretText variable end-to-end (see use-secrettext-for-credentials).
See sample: `samples/security/never-hardcode-secrets-in-al/good.al`.
## Anti Pattern
Assigning a secret literal to a Text, Code, or Label variable (including labels marked as constants). The secret is now part of the compiled app and indistinguishable from non-sensitive content to callers and tools.
See sample: `samples/security/never-hardcode-secrets-in-al/bad.al`.

View file

@ -0,0 +1,25 @@
---
bc-version: [26..28]
domain: security
keywords: [keyvault, azure, secrets, rotation, audit]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Prefer Azure Key Vault for production secrets
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Azure Key Vault is an external secret store that supports central management, rotation, and access auditing. The Business Central system application exposes integration APIs that retrieve Key Vault secrets at runtime. IsolatedStorage, by contrast, is a per-tenant local encrypted store with no central rotation or audit story.
## Best Practice
For production workloads that require secret rotation, access auditing, and separation between secret custodians and app developers, Azure Key Vault SHOULD be the store of record. Retrieve secrets into a SecretText variable on demand, cache only as long as the call requires, and never persist the retrieved plaintext anywhere the extension does not control. IsolatedStorage MAY be used when a per-tenant local encrypted store is all that is required.
## Anti Pattern
Treating IsolatedStorage as the long-term home for secrets in a multi-tenant production extension where secret rotation, central revocation, or access auditing are required.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [https, httpclient, tls, plaintext]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Require HTTPS for external calls
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
HttpClient can issue requests over plaintext HTTP as easily as over HTTPS. A request sent over http:// is transmitted unencrypted, exposing the full URL (including query string), the request headers (including Authorization), and the bodies of both request and response to any on-path observer. This holds even when the payload itself is not marked sensitive — request signatures and session tokens are routinely captured and replayed.
## Best Practice
Call external services exclusively over https://. When the destination is configurable, validate at runtime that the scheme is https before issuing the request, and fail closed with a clear (non-disclosing) error otherwise.
See sample: `samples/security/require-https-for-external-calls/good.al`.
## Anti Pattern
Issuing HttpClient.Get('http://...'), or accepting an arbitrary user-supplied URL and passing it straight to HttpClient without scheme validation.
See sample: `samples/security/require-https-for-external-calls/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [timeout, httpclient, availability, dos]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Set timeouts for external calls
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
An HttpClient with no explicit timeout relies on defaults that may be long enough for a hung or slow endpoint to block a user session or a background task for minutes. A dependency that degrades therefore degrades the caller, and an intentionally slow endpoint is a cheap denial-of-service vector against the extension.
## Best Practice
Set HttpClient.Timeout to a bounded value (seconds, not minutes) that reflects the SLA of the dependency. Handle the timeout error without leaking endpoint details to end users (see avoid-sensitive-data-in-error-messages).
See sample: `samples/security/set-timeouts-for-external-calls/good.al`.
## Anti Pattern
Issuing HttpClient requests without setting Timeout and without a timeout-handling branch. A slow dependency now has an unbounded blast radius inside the extension.
See sample: `samples/security/set-timeouts-for-external-calls/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [indirect-permission, elevation, permissionset]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use indirect permissions for elevated access
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
Indirect permissions (ri, ii, mi, di) let a procedure perform an operation against tabledata the caller does not have direct rights to, provided the caller is authorized to invoke the procedure. They are the supported mechanism for elevation: instead of widening every caller's direct rights to M or D, the sensitive operation lives in a codeunit that holds the indirect right and validates its callers.
## Best Practice
Where a module exposes a controlled write or delete against a sensitive table, grant the codeunit (or the helper permission set it assumes) the indirect permission (mi, di) it requires, keep direct permissions minimal, and document why the elevation is justified. The helper MUST validate its inputs and the caller's identity before performing the elevated work.
See sample: `samples/security/use-indirect-permissions-for-elevated-access/good.al`.
## Anti Pattern
Granting direct M or D on a sensitive tabledata to every role that might invoke a helper, because authoring an indirect-permission codeunit was inconvenient. Every caller now has the elevated right for every code path, not just the one the helper implements.
See sample: `samples/security/use-indirect-permissions-for-elevated-access/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [inherentpermissions, attribute, least-privilege]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use InherentPermissions to grant minimal access
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
The InherentPermissions attribute attaches a minimum access grant to a procedure. Callers can invoke the procedure without holding the underlying tabledata right, because the attribute supplies exactly the right required by the procedure body and nothing more. InherentPermissions currently targets only objects owned by the same extension as the annotated procedure; it cannot be used to grant access to tables in other extensions or in the base application.
## Best Practice
Annotate read-only helper procedures with InherentPermissions specifying only the tables and access letters the body uses (typically 'r'). Callers do not need direct read rights on the underlying extension-owned table, so the calling role can be narrower. This is the narrowest of the elevation options and is appropriate for read-only lookup helpers.
See sample: `samples/security/use-inherent-permissions-to-grant-minimal-access/good.al`.
## Anti Pattern
A helper that reads a single lookup value but forces every calling role to hold tabledata read rights, because the helper does not declare its own inherent permissions. The broad read right then applies to every other code path that role can reach, not just the helper.
See sample: `samples/security/use-inherent-permissions-to-grant-minimal-access/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [isolatedstorage, encryption, datascope, secrets]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use IsolatedStorage for module and company secrets
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
IsolatedStorage is a per-extension, per-tenant key-value store. DataScope::Module isolates values to the extension across the tenant; DataScope::Company scopes them to a single company within the tenant. The SetEncrypted method stores the value encrypted at rest; Set stores it in plaintext. SetEncrypted accepts inputs up to 215 characters (special characters may consume more space).
## Best Practice
Use IsolatedStorage.SetEncrypted to write secrets, IsolatedStorage.Contains to probe, and IsolatedStorage.Get into a SecretText destination to read. Choose DataScope::Company for per-company credentials (for example, a tenant-per-company service account) and DataScope::Module for extension-wide configuration.
See sample: `samples/security/use-isolated-storage-for-module-and-company-secrets/good.al`.
## Anti Pattern
Storing secrets in a Setup table column as plain Text, or using IsolatedStorage.Set (unencrypted) for values that authenticate the extension to an external service. Both shapes leave the secret readable by anyone with read rights on the underlying storage.
See sample: `samples/security/use-isolated-storage-for-module-and-company-secrets/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [nondebuggable, secrettext, attribute, parse]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use NonDebuggable when parsing secrets
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
SecretText transit (assignment between SecretText variables, parameters, and return values) is protected automatically. Extracting a secret from a Text source — for example, reading an access token out of a parsed JSON response — is a legitimate Text-to-SecretText conversion during which the plaintext exists. The [NonDebuggable] attribute prevents debuggers (regular and snapshot) from inspecting the procedure's locals, parameters, and return at that moment.
## Best Practice
Apply [NonDebuggable] to any procedure that reads a response body, parses it, and assigns the extracted secret to a SecretText out-parameter or return. Keep the procedure narrow: it SHOULD do the minimum work required to obtain the SecretText, and nothing else.
See sample: `samples/security/use-nondebuggable-when-parsing-secrets/good.al`.
## Anti Pattern
Parsing a token response in a normal (debuggable) procedure. The plaintext token is visible in debug sessions and snapshots taken during the parse.
See sample: `samples/security/use-nondebuggable-when-parsing-secrets/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [secrettext, credentials, debugger, type]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use SecretText for credentials
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
SecretText is a compile-time-checked AL type for credentials, API keys, tokens, and similar sensitive values. The compiler rejects literal assignments to SecretText and blocks implicit conversion back to Text or Code, which prevents many accidental disclosures via logs, errors, and the debugger (regular and snapshot). A SecretText value remains opaque throughout its lifetime.
## Best Practice
Type every credential-carrying variable, procedure parameter, and return as SecretText. Compose values with SecretStrSubstNo (see compose-secrets-with-secretstrsubstno). For HttpClient integration, see use-secrettext-with-httpclient. When a secret must be extracted from a Text source, contain that conversion in a NonDebuggable procedure (see use-nondebuggable-when-parsing-secrets).
See sample: `samples/security/use-secrettext-for-credentials/good.al`.
## Anti Pattern
Passing credentials around as Text or Code parameters. Every such variable is visible in the debugger and may be captured by error handlers, logs, and telemetry that treat Text as non-sensitive.
See sample: `samples/security/use-secrettext-for-credentials/bad.al`.

View file

@ -0,0 +1,29 @@
---
bc-version: [26..28]
domain: security
keywords: [httpclient, secrettext, headers, uri]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use SecretText with HttpClient
> **Seed article.** Converted from an existing security-review prompt to bootstrap the BCQuality security corpus. Domain stewards should expand, restructure, and refine as needed.
## Description
HttpRequestMessage, HttpHeaders, and HttpContent expose SecretText overloads so credentials never have to be converted back to Text to be sent. Key APIs: HttpRequestMessage.SetSecretRequestUri (for URIs containing secrets), HttpHeaders.Add(name, SecretText) for authorization headers, HttpHeaders.ContainsSecret to probe secret-valued headers, HttpContent.WriteFrom(SecretText) for request bodies, and HttpContent.ReadAs(SecretText) to pull response bodies into a secret destination.
## Best Practice
Use HttpRequestMessage.SetSecretRequestUri when any URI component is sensitive (for example, a per-call API key in the path or query), and send the request with HttpClient.Send. Add Authorization headers as SecretText. Check for the presence of a secret header with ContainsSecret, not Contains.
See sample: `samples/security/use-secrettext-with-httpclient/good.al`.
## Anti Pattern
Materializing a URI or header value as Text to 'just get it to compile' — for example, StrSubstNo into a Text and then HttpClient.Get(FullUrl, Response). The resulting Text is visible in debuggers, and the URL is typically captured by platform-level logging the extension does not control.
See sample: `samples/security/use-secrettext-with-httpclient/bad.al`.

View file

@ -18,7 +18,7 @@ Some articles only have a `good.al` (best practice only) or only a `bad.al` (pur
## Status
All samples are **demonstration-only**. They are self-contained AL objects with object IDs in the 50100-50199 range and are not meant to be deployed, nor are they derived from Microsoft's Business Central base application source. They exist to make the accompanying knowledge articles concrete for human readers and for agents that benefit from a worked example.
All samples are **demonstration-only**. They are self-contained AL objects with object IDs in the 50100-50299 range and are not meant to be deployed, nor are they derived from Microsoft's Business Central base application source. They exist to make the accompanying knowledge articles concrete for human readers and for agents that benefit from a worked example.
## Referencing samples from knowledge articles

View file

@ -0,0 +1,14 @@
codeunit 50225 "Sec Sample ErrorDisclosure Bad"
{
procedure Connect()
begin
if not TryConnect() then
Error('Failed to connect to Server=PROD-SQL01;Database=NAV;User=svc_admin: %1', GetLastErrorText());
end;
[TryFunction]
local procedure TryConnect()
begin
// ...
end;
}

View file

@ -0,0 +1,24 @@
codeunit 50224 "Sec Sample ErrorDisclosure Good"
{
var
ConnectionFailedErr: Label 'Connection to the external service failed. Contact your administrator.';
procedure Connect()
begin
if not TryConnect() then begin
LogConnectionFailure(GetLastErrorText());
Error(ConnectionFailedErr);
end;
end;
[TryFunction]
local procedure TryConnect()
begin
// ...
end;
local procedure LogConnectionFailure(Detail: Text)
begin
// Route to controlled logging (Session.LogMessage, activity log, etc.).
end;
}

View file

@ -0,0 +1,9 @@
codeunit 50215 "Sec Sample SecretCompose Bad"
{
procedure BuildAuthHeader(Token: Text) AuthHeader: Text
begin
// Token is Text, so the combined value is plaintext.
// The whole shape should have used SecretText + SecretStrSubstNo.
AuthHeader := StrSubstNo('Bearer %1', Token);
end;
}

View file

@ -0,0 +1,7 @@
codeunit 50214 "Sec Sample SecretCompose Good"
{
procedure BuildAuthHeader(Token: SecretText) AuthHeader: SecretText
begin
AuthHeader := SecretStrSubstNo('Bearer %1', Token);
end;
}

View file

@ -0,0 +1,19 @@
codeunit 50229 "Sec Sample EventPublisher Bad"
{
[IntegrationEvent(false, false)]
local procedure OnBeforeExportCustomer(CustomerNo: Code[20]; ExportCredentials: SecretText; var AllowExport: Boolean)
begin
end;
procedure ExportCustomer(CustomerNo: Code[20]; Credentials: SecretText)
var
AllowExport: Boolean;
begin
// Any subscriber on the tenant receives the credentials and
// can flip AllowExport := true to bypass the publisher's check.
OnBeforeExportCustomer(CustomerNo, Credentials, AllowExport);
if not AllowExport then
exit;
// ... perform export
end;
}

View file

@ -0,0 +1,23 @@
codeunit 50228 "Sec Sample EventPublisher Good"
{
[IntegrationEvent(false, false)]
local procedure OnBeforeExportCustomer(CustomerNo: Code[20])
begin
end;
procedure ExportCustomer(CustomerNo: Code[20])
begin
if not CallerIsAuthorizedToExport(CustomerNo) then
Error('You are not authorized to export this customer.');
OnBeforeExportCustomer(CustomerNo);
// ... perform export using credentials owned by this codeunit
end;
local procedure CallerIsAuthorizedToExport(CustomerNo: Code[20]): Boolean
begin
// Authorization decision stays inside the publisher. Subscribers
// receive only the customer number and cannot influence the
// decision.
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50223 "Sec Sample UrlCreds Bad"
{
procedure Call(ApiKey: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
Client.Get('https://api.example.com/v1/items?api_key=' + ApiKey, Response);
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50222 "Sec Sample UrlCreds Good"
{
procedure Call(ApiKey: SecretText)
var
Client: HttpClient;
Response: HttpResponseMessage;
AuthHeader: SecretText;
begin
AuthHeader := SecretStrSubstNo('Bearer %1', ApiKey);
Client.DefaultRequestHeaders.Add('Authorization', AuthHeader);
Client.Get('https://api.example.com/v1/items', Response);
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50227 "Sec Sample SwallowErr Bad"
{
procedure Authenticate(): Boolean
begin
if not TryAuthenticate() then
exit(false);
exit(true);
end;
[TryFunction]
local procedure TryAuthenticate()
begin
// ...
end;
}

View file

@ -0,0 +1,24 @@
codeunit 50226 "Sec Sample SwallowErr Good"
{
procedure Authenticate(): Boolean
begin
if TryAuthenticate() then
exit(true);
LogAuthFailure(GetLastErrorText());
exit(false);
end;
[TryFunction]
local procedure TryAuthenticate()
begin
// ...
end;
local procedure LogAuthFailure(Detail: Text)
begin
Session.LogMessage('SEC0001', 'Authentication failed', Verbosity::Warning,
DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher,
'Detail', Detail);
end;
}

View file

@ -0,0 +1,6 @@
permissionset 50201 "Sec Sample Full Access"
{
Assignable = true;
Caption = 'Full Access (sample anti-pattern)';
Permissions = tabledata * = RIMD;
}

View file

@ -0,0 +1,9 @@
permissionset 50200 "Sec Sample Sales Order Entry"
{
Assignable = true;
Caption = 'Sales Order Entry (sample)';
Permissions =
tabledata "Sales Header" = RIM,
tabledata "Sales Line" = RIMD,
tabledata Customer = R;
}

View file

@ -0,0 +1,10 @@
codeunit 50207 "Sec Sample HardcodedSecret Bad"
{
var
HardcodedApiKeyLbl: Label 'sk-live-1234567890abcdef', Locked = true;
procedure GetApiKey(): Text
begin
exit(HardcodedApiKeyLbl);
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50206 "Sec Sample HardcodedSecret Good"
{
procedure GetApiKey() ApiKey: SecretText
var
StoredValue: SecretText;
begin
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
if IsolatedStorage.Get('ApiKey', DataScope::Module, StoredValue) then
exit(StoredValue);
Error('API key is not configured.');
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50219 "Sec Sample Https Bad"
{
procedure CallExternal()
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
Client.Get('http://api.example.com/data', Response);
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50218 "Sec Sample Https Good"
{
procedure CallExternal(Endpoint: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
if not Endpoint.StartsWith('https://') then
Error('Only HTTPS endpoints are allowed.');
Client.Get(Endpoint, Response);
end;
}

View file

@ -0,0 +1,11 @@
codeunit 50221 "Sec Sample Timeout Bad"
{
procedure CallExternal()
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
// No Timeout set; a hung endpoint stalls the caller.
Client.Get('https://api.example.com/data', Response);
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50220 "Sec Sample Timeout Good"
{
procedure CallExternal()
var
Client: HttpClient;
Response: HttpResponseMessage;
begin
Client.Timeout := 10000; // 10 seconds
if not Client.Get('https://api.example.com/data', Response) then
Error('External service is unavailable.');
end;
}

View file

@ -0,0 +1,7 @@
permissionset 50203 "Sec Sample Direct Write"
{
Assignable = true;
Caption = 'Direct write granted to every caller (sample anti-pattern)';
Permissions =
tabledata "Sales Header" = RM;
}

View file

@ -0,0 +1,34 @@
permissionset 50202 "Sec Sample Elevated Write"
{
Assignable = false;
Caption = 'Elevated write via helper (sample)';
// Callers hold R directly; the helper codeunit assumes this set and
// performs the Modify via indirect permission.
Permissions =
tabledata "Sales Header" = Rmi;
}
codeunit 50231 "Sec Sample Elevated Helper"
{
Access = Public;
Permissions = tabledata "Sales Header" = Rmi;
procedure SetExternalDocumentNo(SalesDocType: Enum "Sales Document Type"; SalesDocNo: Code[20]; NewExternalDocNo: Code[35])
var
SalesHeader: Record "Sales Header";
begin
ValidateCaller();
if NewExternalDocNo = '' then
Error('External document number must be provided.');
if not SalesHeader.Get(SalesDocType, SalesDocNo) then
Error('Sales document not found.');
SalesHeader."External Document No." := NewExternalDocNo;
SalesHeader.Modify(true);
end;
local procedure ValidateCaller()
begin
// Verify the caller is permitted to perform this elevated write
// (role check, setup flag, approvals, etc.).
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50205 "Sec Sample Inherent Bad"
{
// No InherentPermissions attribute: every caller must hold
// tabledata "Sec Sample Lookup" = R just to look up a name.
procedure GetLookupName(LookupCode: Code[20]): Text[100]
var
Lookup: Record "Sec Sample Lookup";
begin
if Lookup.Get(LookupCode) then
exit(Lookup.Name);
exit('');
end;
}

View file

@ -0,0 +1,28 @@
table 50230 "Sec Sample Lookup"
{
DataClassification = SystemMetadata;
fields
{
field(1; "Code"; Code[20]) { }
field(2; "Name"; Text[100]) { }
}
keys
{
key(PK; "Code") { Clustered = true; }
}
}
codeunit 50204 "Sec Sample Inherent Good"
{
[InherentPermissions(PermissionObjectType::TableData, Database::"Sec Sample Lookup", 'r')]
procedure GetLookupName(LookupCode: Code[20]): Text[100]
var
Lookup: Record "Sec Sample Lookup";
begin
if Lookup.Get(LookupCode) then
exit(Lookup.Name);
exit('');
end;
}

View file

@ -0,0 +1,17 @@
codeunit 50209 "Sec Sample IsolatedStorage Bad"
{
procedure StoreApiKey(NewKey: Text)
begin
// Plaintext write to IsolatedStorage is not encrypted at rest.
IsolatedStorage.Set('ApiKey', NewKey, DataScope::Module);
end;
procedure GetApiKey(): Text
var
ApiKey: Text;
begin
if IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey) then
exit(ApiKey);
exit('');
end;
}

View file

@ -0,0 +1,14 @@
codeunit 50208 "Sec Sample IsolatedStorage Good"
{
procedure StoreApiKey(NewKey: SecretText)
begin
IsolatedStorage.SetEncrypted('ApiKey', NewKey, DataScope::Module);
end;
procedure TryGetApiKey(var ApiKey: SecretText): Boolean
begin
if IsolatedStorage.Contains('ApiKey', DataScope::Module) then
exit(IsolatedStorage.Get('ApiKey', DataScope::Module, ApiKey));
exit(false);
end;
}

View file

@ -0,0 +1,16 @@
codeunit 50217 "Sec Sample NonDebuggable Bad"
{
// Missing [NonDebuggable]: ResponseText and the extracted token are
// inspectable in the debugger and in snapshot debug sessions.
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
var
ResponseText: Text;
JObject: JsonObject;
JToken: JsonToken;
begin
Response.Content.ReadAs(ResponseText);
JObject.ReadFrom(ResponseText);
JObject.Get('access_token', JToken);
SessionToken := JToken.AsValue().AsText();
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50216 "Sec Sample NonDebuggable Good"
{
[NonDebuggable]
procedure ParseSessionToken(Response: HttpResponseMessage; var SessionToken: SecretText)
var
ResponseText: Text;
JObject: JsonObject;
JToken: JsonToken;
begin
Response.Content.ReadAs(ResponseText);
JObject.ReadFrom(ResponseText);
JObject.Get('access_token', JToken);
SessionToken := JToken.AsValue().AsText();
end;
}

View file

@ -0,0 +1,13 @@
codeunit 50211 "Sec Sample SecretText Bad"
{
procedure SendAuthenticatedRequest(BearerToken: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
AuthValue: Text;
begin
AuthValue := 'Bearer ' + BearerToken;
Client.DefaultRequestHeaders.Add('Authorization', AuthValue);
Client.Get('https://api.example.com/data', Response);
end;
}

View file

@ -0,0 +1,14 @@
codeunit 50210 "Sec Sample SecretText Good"
{
procedure SendAuthenticatedRequest(BearerToken: SecretText)
var
Client: HttpClient;
Headers: HttpHeaders;
Response: HttpResponseMessage;
AuthValue: SecretText;
begin
AuthValue := SecretStrSubstNo('Bearer %1', BearerToken);
Client.DefaultRequestHeaders.Add('Authorization', AuthValue);
Client.Get('https://api.example.com/data', Response);
end;
}

View file

@ -0,0 +1,12 @@
codeunit 50213 "Sec Sample SecretHttpClient Bad"
{
procedure Call(ApiKey: Text)
var
Client: HttpClient;
Response: HttpResponseMessage;
FullUrl: Text;
begin
FullUrl := 'https://api.example.com/v1?key=' + ApiKey;
Client.Get(FullUrl, Response);
end;
}

View file

@ -0,0 +1,15 @@
codeunit 50212 "Sec Sample SecretHttpClient Good"
{
procedure Call(ApiKey: SecretText)
var
Client: HttpClient;
Request: HttpRequestMessage;
Response: HttpResponseMessage;
SecretUri: SecretText;
begin
SecretUri := SecretStrSubstNo('https://api.example.com/v1?key=%1', ApiKey);
Request.SetSecretRequestUri(SecretUri);
Request.Method('GET');
Client.Send(Request, Response);
end;
}