Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,16 @@ would have stopped while it is still in dry-run. Two separate paths, with differ

What a detection report contains, per matched rule — on every phase, whatever fired it: the rule id, the revision of the rule when the
bundle carried one, the identifier of the rule bundle in use, which phase matched,
whether it was enforced, the request path **with the query string's values removed**,
that query's parameter names, the method, and a timestamp. Each batch also carries a count of reports dropped when traffic outran the flush,
the rule's category and the action it declares, whether it was enforced,
the request path **with the query string's values removed**,
that query's parameter names, the method, and a timestamp.

The category and the declared action say what KIND of rule matched — "a secret-exposure rule that
redacts", "an SSRF rule that blocks". Both are read from the rule your guard was served, and both are
`null` when that rule declares neither: a rule whose class nobody can state is reported as one, not
filled in. Neither is the same as `enforced`, which is whether the rule acted on this particular
request: a rule declaring `block` while only observing reports exactly that, and that is what a
detect-only deployment consists of. Each batch also carries a count of reports dropped when traffic outran the flush,
so a partial sample is not read as a complete one.

Two fields depend on the phase, because one kind of detection has a client and the other does not. A
Expand Down
5 changes: 5 additions & 0 deletions src/protect/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@ export const DEFAULT_EGRESS_RULES = [
title: 'Outbound request to an internal / metadata address (SSRF)',
phase: 'egress',
category: 'ssrf',
// Declared, not implied. Nothing in the egress path reads it — a match refuses the call, and the
// rule's mode decides whether it actually did — but the action is part of how a rule describes
// itself to whatever reports on it, and a rule that declares nothing is reported as a rule nobody
// can classify.
action: 'block',
rule_v2: [{ parameter: 'egress.host', match: { type: 'internal_host' } }]
}
];
118 changes: 116 additions & 2 deletions src/protect/detections.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { pulseFetch } from '../pulse-token.js';
import { ACTIONS } from './rules/contract.js';
import { clientIpFields } from './client-ip.js';
import { readRuleParameters, ruleParameters } from './rule-parameters.js';
import { isSafeOrigin } from './safe-origin.js';
Expand All @@ -17,8 +18,13 @@ import { isSafeOrigin } from './safe-origin.js';
* ## The payload is deliberately small
*
* `rule_id`, route PATH, the parameters the rule reads, a timestamp, whether it was enforced, the phase,
* the bundle identity, and the rule's own revision where the bundle carried one. That is enough to count
* hits per rule, compare them against traffic, and decide whether a rule is wrong.
* the rule's category and the action it declares, the bundle identity, and the rule's own revision where
* the bundle carried one. That is enough to count hits per rule, compare them against traffic, and
* decide whether a rule is wrong.
*
* The category and the declared action say what KIND of rule matched, so a redaction and a blocked
* request are not one number. Both come from the rule and are `null` when it declares neither.
* Neither is `enforced`: a rule declaring `block` while only observing enforces nothing.
*
* It also carries the values of the parameters the matched rule NAMES, under a plan derived from that
* rule — because counting that a rule fired is not enough to act on it. What may be captured is the
Expand Down Expand Up @@ -98,6 +104,69 @@ const ATTEMPT_TIMEOUT_MS = 10_000;
*/
const STOP_BUDGET_MS = 5_000;

/**
* The shape a category has to have to mean anything to the platform receiving it.
*
* No length bound here: the bound is applied separately, so a category that is a real slug and merely
* too long can be told apart from one that was never a category. Only the first has anything to say
* about the bound.
*
* A slug. `unknown` is excluded because that is the word the platform files an ABSENT class under, so a
* category spelled that way would be sent as a real class and then counted as a missing one — two
* spellings of one thing, disagreeing about which they are.
*/
const CATEGORY_PATTERN = /^[a-z][a-z0-9-]*$/;
const RESERVED_CATEGORY = 'unknown';

/**
* A rule's declared class, as it can be reported.
*
* Four outcomes, and the difference between the last two is the point:
*
* - absent → `null`, unmarked. The rule declared nothing, which is a true report.
* - unrecognisable → `null`, unmarked. Not a class this platform has a meaning for, so reporting it
* would put arbitrary text where a class belongs — and nothing was lost, which is what the mark is
* about.
* - recognisable but too long → `null`, MARKED. There was a class here and it could not be carried: it
* cannot be shortened without becoming a different class, and one sent as a class would be a category
* nobody authored, counted beside real ones.
* - recognisable and short enough → carried as it is.
*
* Recognition is tested BEFORE the length, and the order is the whole of what the mark means. Reversed,
* any long enough run of nonsense is reported as "there was a class here that would not fit" — which is
* a claim about a class that never existed, and the reader has no way to see it is false. Only a value
* that would have been carried at a shorter length has anything to say about the bound.
*
* Recognition happens here rather than only at the far end. Both sides work from the same contract, and
* a value the receiver will refuse costs an event's budget to be discarded — while travelling as though
* it were known, which is the part that misleads: `Uppercase` or `has spaces` would arrive looking like
* a class and be filed as a missing one.
*
* @param {unknown} declared
* @param {(value: string) => boolean} recognised
* @returns {{ value: string | null, dropped: boolean }}
*/
function declaredClass(declared, recognised) {
if (typeof declared !== 'string' || declared === '') return { value: null, dropped: false };
if (!recognised(declared)) return { value: null, dropped: false };
if (declared.length > MAX_CLASS_CHARS) return { value: null, dropped: true };

return { value: declared, dropped: false };
}

/** @param {string} value */
const recognisedCategory = (value) => value !== RESERVED_CATEGORY && CATEGORY_PATTERN.test(value);

/**
* The actions a rule may declare, from the rule contract this package already enforces.
*
* Imported rather than restated: a list written out here would be a second copy of the vocabulary, and
* the copy that fell behind would file a real action as unrecognisable.
*
* @param {string} value
*/
const recognisedAction = (value) => ACTIONS.includes(value);

/**
* Size bounds, applied per event and per batch.
*
Expand All @@ -118,6 +187,18 @@ const MAX_PARAMETER_CHARS = 64;
* reader must not use it as a key believing it does.
*/
const MAX_IDENTIFIER_CHARS = 256;
/**
* The bound on a rule's declared class, set by what the platform accepts rather than by what fits.
*
* A longer identifier can be shortened and still be useful: a route that was cut is still the route
* prefix. A class cannot. `secret-exposure` shortened is a different class, and a shortened one sent as
* a class would be a category nobody authored, counted alongside real ones.
*
* So a class beyond the bound is reported as ABSENT and marked, not shortened. The platform would refuse
* it anyway — sending a value known to be unacceptable only spends the event's budget to have it
* discarded, and sending a truncated one spends it to have the wrong thing recorded.
*/
const MAX_CLASS_CHARS = 64;
/**
* Bounds re-applied to captured evidence at the wire.
*
Expand Down Expand Up @@ -878,13 +959,30 @@ export function createDetectionReporter(opts) {
const id = capText(String(ruleId), MAX_IDENTIFIER_CHARS);
const revision = capText(revisionOf(detection.rule) ?? '', MAX_IDENTIFIER_CHARS);
const etag = capText(rulesEtag ?? '', MAX_IDENTIFIER_CHARS);
// Both declared fields come from the RULE, which is the only thing that declares them.
//
// A detection also carries a top-level `category` copied from the rule at each site that raises
// one. Reading that copy would make this report depend on every one of those copies staying
// right: a site that forgot one, or set it from something else, would report a class the rule
// does not have — and it would look exactly like a correct report. The rule is the source, so
// there is nothing to keep in step.
const classCategory = declaredClass(detection.rule?.category, recognisedCategory);
const classAction = declaredClass(detection.rule?.action, recognisedAction);
for (const [name, field] of [
['rule_id', id],
['rule_revision', revision],
['rules_etag', etag],
]) {
if (field.truncated) truncated.push(name);
}
// Marked when a class was there and could not be carried, so its absence is never read as a rule
// that declared nothing.
for (const [name, field] of [
['category', classCategory],
['action', classAction],
]) {
if (field.dropped) truncated.push(name);
}

queue.push({
rule_id: id.value,
Expand All @@ -908,7 +1006,23 @@ export function createDetectionReporter(opts) {
...(query.total > queryKeys.length ? { query_keys_total: query.total } : {}),
// Who asked. Capped, since it is client-supplied text and this is an event with a size bound.
user_agent: userAgent === null ? null : userAgent.value,
// What KIND of match this was: which phase it happened in, what class of thing the rule is for,
// and what the rule DECLARES it does about it.
//
// Three separate facts, and none of them is `enforced` below. A rule declaring `block` while
// observing reports exactly that — `action: 'block'`, `enforced: false` — which is what a
// dry-run window consists of. Reading either off the other would describe such a window as
// protection that never happened, or as rules that do nothing.
//
// `null` where the rule says nothing, never a guess. A consumer can tell "this rule is for
// secret exposure" from "we cannot say what this rule is for", and a filled-in value would take
// that distinction away for the sake of a tidier field.
phase: detection.phase ?? null,
// `null` where the rule declared nothing, and also where what it declared could not be carried:
// both are "we cannot say what this rule is for", which is a different fact from a class we do
// know, and `truncated` distinguishes the second from the first.
category: classCategory.value,
action: classAction.value,
// The state this detection was handled under, which is the whole point: `false` is a rule that
// saw traffic it would have stopped.
enforced: detection.mode === 'block',
Expand Down
8 changes: 6 additions & 2 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,12 @@ export interface CreateProtectionOptions {
*
* What it sends on EVERY detection: the rule id and its revision, the request path, the query string's
* parameter NAMES, the method, the parameters the rule reads, the phase, whether it was enforced, the
* rule-bundle ETag, and a timestamp — plus the values of the parameters the matched rule names, under a
* capture plan derived from that rule.
* rule-bundle ETag, a timestamp, and the rule's category and the action it declares — plus
* the values of the parameters the matched rule names, under a plan derived from that rule.
*
* The category and the declared action say what KIND of rule matched. Both are read from the rule
* itself and are `null` when it declares neither. Neither is the same as `enforced`:
* a rule declaring `block` while only observing reports that action with `enforced` false.
*
* Two fields depend on the phase. A request or response detection also carries the user agent and the
* client address with its provenance. An egress detection carries neither: the call was the
Expand Down
12 changes: 12 additions & 0 deletions tests/endpoint-disclosure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ describe('shipped docs disclose every endpoint the package calls', () => {
expect(text, 'must scope the client fields to the phases that have a client').toMatch(
/request or response detection/i,
);
// The classification, on the same terms. A payload field described in AGENT-INSTALL.md and
// nowhere else is documented for whoever reads that file and undocumented for the caller whose
// editor shows the type declaration — and the completeness check over the payload reads only
// AGENT-INSTALL.md, so it cannot notice.
expect(text, "must say the rule's class travels").toMatch(
/category and the action it declares/i,
);
// And that it is not the same fact as enforcement, which is the pair a reader is most likely to
// conflate: a rule declaring `block` while observing enforces nothing.
expect(text, 'must separate the declared action from what was enforced').toMatch(
/declaring `?block`? while (only )?observing/i,
);
expect(text, 'and must say an egress detection carries neither').toMatch(
/egress detection[^.]*(carries neither|no user agent)/i,
);
Expand Down
Loading
Loading