@redsift/products 12.5.12-alpha.3 → 12.5.12-alpha.4

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.
@@ -539,7 +539,31 @@ var EXTRACTOR_STRINGS = {
539
539
  "investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** of unauthorized emails with the [](FROM#:glossary) `{domain}` will be **rejected**, but subdomains use a weaker policy (sp={sp}), leaving them vulnerable to spoofing.",
540
540
  "investigate:card.dmarc.misplaced-bad-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced and has a syntax error:**",
541
541
  "investigate:card.dmarc.misplaced-policy_md": "**The [FULL](DMARC_POLICY#:glossary) is misplaced.**",
542
- "investigate:card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Receivers stop policy discovery and apply no DMARC policy at all, so the domain is unprotected. Publish exactly one DMARC record.",
542
+ // WEB-1243: standards-neutral. The previous wording said receivers "stop policy
543
+ // discovery and apply no DMARC policy at all", which is RFC 7489 §6.6.3 step 5 only.
544
+ // RFC 9989 §4.10 receivers discard the duplicates and CONTINUE the tree walk, so an
545
+ // ancestor policy may still apply — and DNS-DMARC-0044, the live signal on current
546
+ // production, is emitted by exactly those receivers. Stating the RFC 7489 behaviour
547
+ // as universal was therefore false for the code that now triggers this message.
548
+ // Rewritten to assert only what is true on both: the outcome is receiver-dependent
549
+ // and therefore unreliable, and the fix is the same either way.
550
+ "investigate:card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
551
+ // WEB-1243: the UNCONFIRMED counterpart. `indeterminate` means the check could not
552
+ // finish, NOT that duplicates were found. It resolves to a danger status on purpose —
553
+ // an unproven absence must not read as a proven one on a security signal — but the copy
554
+ // must not then assert the conflict as fact. `multiple-policies_md` says duplicates ARE
555
+ // published and to publish exactly one record, which on this path would tell a user
556
+ // with a healthy single record to change it.
557
+ //
558
+ // DELIBERATELY CAUSE-NEUTRAL. `indeterminate` has more than one cause: the scan budget
559
+ // running out, AND a candidate entry the check could not read (a rendered display group
560
+ // reaching a semantic position). An earlier draft said "the domain published more
561
+ // records than this check inspects", which is a FALSE statement of fact on the second
562
+ // cause — a one-name payload got told it had published too many records. Naming any
563
+ // single cause here is wrong unless the reason is carried alongside the verdict, so the
564
+ // copy states only what is true of every path: not everything was inspected, and the
565
+ // reader should go and look.
566
+ "investigate:card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
543
567
  "investigate:card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
544
568
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "No [](TXT_RECORDS#:glossary) published on `{policyDomain}`",
545
569
  "investigate:card.dmarc.permerror-no-rua_md": "No valid reporting URI ([](RUA#:glossary)) is present, so receivers will skip DMARC processing entirely.",
@@ -1026,7 +1050,8 @@ var EXTRACTOR_STRINGS = {
1026
1050
  "investigate:card.dmarc.full-rejection-subdomain-weak_md": "**100%** des courriels non autoris\xE9s avec le [](FROM#:glossary) `{domain}` seront **rejet\xE9s**, mais les sous-domaines utilisent une politique plus faible (sp={sp}), les laissant vuln\xE9rables \xE0 l'usurpation.",
1027
1051
  "investigate:card.dmarc.misplaced-bad-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9 et a une erreur de syntaxe:**",
1028
1052
  "investigate:card.dmarc.misplaced-policy_md": "**Le [FULL](DMARC_POLICY#:glossary) est mal plac\xE9.",
1029
- "investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.** Les destinataires interrompent la d\xE9couverte de la politique et n'appliquent aucune politique DMARC, le domaine n'est donc pas prot\xE9g\xE9. Publiez un seul enregistrement DMARC.",
1053
+ "investigate:card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publi\xE9es sur `{policyDomain}`.** Le traitement d\xE9pend du destinataire : certains interrompent la d\xE9couverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publi\xE9s. Publiez un seul enregistrement DMARC.",
1054
+ "investigate:card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu v\xE9rifier enti\xE8rement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publi\xE9s sur `{policyDomain}`.** Cette v\xE9rification n'a pas inspect\xE9 tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publi\xE9s, votre politique ne peut \xEAtre consid\xE9r\xE9e comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
1030
1055
  "investigate:card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publi\xE9e sur le domaine**\n\n**`{policyDomain}`**",
1031
1056
  "investigate:card.dmarc.no-txt-published-policy-on-domain_md": "Aucun [](TXT_RECORD#:glossary) publi\xE9 sur `{policyDomain}`",
1032
1057
  "investigate:card.dmarc.permerror-no-rua_md": "Aucun URI de rapport valide ([](RUA#:glossary)) n'est pr\xE9sent, les destinataires ignoreront enti\xE8rement le traitement DMARC.",
@@ -1316,6 +1341,7 @@ var enUS = {
1316
1341
  "investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "The remaining **{remainingPercent}%** of unauthorized emails will be subject to the **quarantine** policy.",
1317
1342
  "investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "The remaining **{remainingPercent}%** of unauthorized emails will be **delivered normally**.",
1318
1343
  "investigate.card.dmarc.no-policy_md": "**No [FULL](DMARC_POLICY#:glossary) published on domain**\n\n**`{policyDomain}`**",
1344
+ "investigate.card.dmarc.multiple-policies_md": "**More than one [FULL](DMARC_POLICY#:glossary) is published on `{policyDomain}`.** Handling depends on the receiver: some stop policy discovery and apply no DMARC policy at all, others discard the duplicate records and continue. Your policy cannot be relied on for any recipient while more than one record is published. Publish exactly one DMARC record.",
1319
1345
  "investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **passed**",
1320
1346
  "investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) is BIMI compliant **BUT** it does not have an **aggregate report address** (rua) entry.",
1321
1347
  "investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) does not have an **aggregate report address** (rua) entry.",
@@ -1935,7 +1961,14 @@ var enUS = {
1935
1961
  "investigate.card.dmarc.domain-aligned": "The domain is aligned with the From Domain. It can contribute to passing DMARC.",
1936
1962
  "investigate.card.dmarc.domain-aligned-not-verified": "The domain is aligned with the From Domain, but its signature did not verify, so it cannot contribute to passing DMARC.",
1937
1963
  "investigate.card.dmarc.spf-domain-aligned-not-verified": "The domain is aligned with the From Domain, but SPF did not pass, so it cannot contribute to passing DMARC.",
1938
- "investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC."
1964
+ "investigate.card.dmarc.domain-not-aligned": "The domain is not aligned with the From Domain. It cannot contribute to passing DMARC.",
1965
+ "investigate.card.dmarc.multiple-policies-unconfirmed_md": "**We could not fully check how many [FULL](DMARC_POLICY#:glossary) records are published on `{policyDomain}`.** This check did not inspect every record, so we cannot confirm whether more than one is live. If more than one is published, your policy cannot be relied on for any recipient. Review the DNS zone for `{policyDomain}` and confirm it publishes exactly one DMARC record.",
1966
+ "investigate.card.extracted.truncated-records": "[… {count} more records not shown]",
1967
+ "investigate.card.extracted.truncated-records_one": "[… 1 more record not shown]",
1968
+ "investigate.card.extracted.truncated-records-atleast": "[… at least {count} more records not shown]",
1969
+ "investigate.card.extracted.truncated-records-uninspected": "[… further records not inspected]",
1970
+ "investigate.card.extracted.truncated-record-length": "[… long records shortened for display]",
1971
+ "investigate.card.extracted.truncated-records-atleast_one": "[… at least 1 more record not shown]"
1939
1972
  };
1940
1973
 
1941
1974
  var frFR = {
@@ -2034,6 +2067,7 @@ var frFR = {
2034
2067
  "investigate.card.dmarc.policy-remaining-emails-pct-reject_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront soumis à la politique de **mise en quarantaine**.",
2035
2068
  "investigate.card.dmarc.policy-remaining-emails-pct-quarantine_md": "Les **{remainingPercent}%** restants de courriels non autorisés seront **livrés normalement**.",
2036
2069
  "investigate.card.dmarc.no-policy_md": "**Aucune [FULL](DMARC_POLICY#:glossary) publiée sur le domaine**\n\n**`{policyDomain}`**",
2070
+ "investigate.card.dmarc.multiple-policies_md": "**Plusieurs [FULL](DMARC_POLICY#:glossary) sont publiées sur `{policyDomain}`.** Le traitement dépend du destinataire : certains interrompent la découverte de la politique et n'appliquent aucune politique DMARC, d'autres ignorent les enregistrements en double et continuent. Votre politique ne peut être considérée comme fiable pour aucun destinataire tant que plusieurs enregistrements sont publiés. Publiez un seul enregistrement DMARC.",
2037
2071
  "investigate.card.dmarc.dmarc-passed_md": "[](DMARC#:glossary) **réussi**",
2038
2072
  "investigate.card.dmarc.dmarc-passed-no-rua_md": "[](DMARC#:glossary) est conforme à BIMI **MAIS** n'a pas d'**adresse de rapport agrégé** (rua).",
2039
2073
  "investigate.card.dmarc.dmarc-no-rua_md": "[](DMARC#:glossary) n'a pas d'**adresse de rapport agrégé** (rua).",
@@ -2655,7 +2689,14 @@ var frFR = {
2655
2689
  "investigate.card.dmarc.domain-aligned": "Le domaine est aligné avec le domaine expéditeur. Il peut contribuer à la réussite de DMARC.",
2656
2690
  "investigate.card.dmarc.domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais sa signature n'a pas été vérifiée, il ne peut donc pas contribuer à la réussite de DMARC.",
2657
2691
  "investigate.card.dmarc.spf-domain-aligned-not-verified": "Le domaine est aligné avec le domaine expéditeur, mais SPF n'a pas réussi, il ne peut donc pas contribuer à la réussite de DMARC.",
2658
- "investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC."
2692
+ "investigate.card.dmarc.domain-not-aligned": "Le domaine n'est pas aligné avec le domaine expéditeur. Il ne peut pas contribuer à la réussite de DMARC.",
2693
+ "investigate.card.dmarc.multiple-policies-unconfirmed_md": "**Nous n'avons pas pu vérifier entièrement combien d'enregistrements [FULL](DMARC_POLICY#:glossary) sont publiés sur `{policyDomain}`.** Cette vérification n'a pas inspecté tous les enregistrements, nous ne pouvons donc pas confirmer si plusieurs sont actifs. Si plusieurs sont publiés, votre politique ne peut être considérée comme fiable pour aucun destinataire. Examinez la zone DNS de `{policyDomain}` et confirmez qu'elle publie un seul enregistrement DMARC.",
2694
+ "investigate.card.extracted.truncated-records": "[… {count} enregistrements supplémentaires non affichés]",
2695
+ "investigate.card.extracted.truncated-records_one": "[… 1 enregistrement supplémentaire non affiché]",
2696
+ "investigate.card.extracted.truncated-records-atleast": "[… au moins {count} enregistrements supplémentaires non affichés]",
2697
+ "investigate.card.extracted.truncated-records-uninspected": "[… enregistrements supplémentaires non examinés]",
2698
+ "investigate.card.extracted.truncated-record-length": "[… enregistrements longs raccourcis pour l’affichage]",
2699
+ "investigate.card.extracted.truncated-records-atleast_one": "[… au moins 1 enregistrement supplémentaire non affiché]"
2659
2700
  };
2660
2701
 
2661
2702
  /**
@@ -4828,8 +4869,49 @@ const CodeBlock = styled.div`
4828
4869
  display: none !important;
4829
4870
  }
4830
4871
  `;
4872
+
4873
+ /**
4874
+ * One logical line inside a `CodeBlock` — typically a single published DNS record.
4875
+ *
4876
+ * DS-115: a domain can publish more than one competing `_dmarc` TXT record, and the card
4877
+ * must show them as distinct records. `CodeBlock` forces `p { display: inline }`, which
4878
+ * flowed them into one unbroken line, so multi-line values use this block-level wrapper
4879
+ * instead of a `<p>`.
4880
+ */
4881
+ const CodeBlockLine = styled.div`
4882
+ display: block;
4883
+ white-space: pre-wrap;
4884
+ word-break: break-all;
4885
+
4886
+ & + & {
4887
+ margin-top: 4px;
4888
+ }
4889
+ `;
4890
+
4891
+ /**
4892
+ * Renderer commentary about a truncated literal value — NOT zone content.
4893
+ *
4894
+ * DS-115. Deliberately its own component rather than a reused `ExtractionCaption`: that one
4895
+ * is `position: absolute`, anchored to an `ExtractionBox`, so on the paths that have no
4896
+ * positioned ancestor it would escape its container, and inside a box it would land on the
4897
+ * same coordinates as the ordinary caption. This stays in normal flow directly beneath the
4898
+ * code block.
4899
+ *
4900
+ * Visually distinct from `CodeBlockLine` on purpose — italic, not monospace — because a zone
4901
+ * can publish a record whose text imitates this notice, and the two must not be confusable.
4902
+ */
4903
+ const LiteralTruncationNotice = styled.div`
4904
+ display: block;
4905
+ margin-top: 6px;
4906
+ font-style: italic;
4907
+ font-size: 12px;
4908
+ opacity: 0.75;
4909
+ word-break: break-word;
4910
+ `;
4831
4911
  ExtractionBox.displayName = 'ExtractionBox';
4832
4912
  ExtractionCaption.displayName = 'ExtractionCaption';
4913
+ CodeBlockLine.displayName = 'CodeBlockLine';
4914
+ LiteralTruncationNotice.displayName = 'LiteralTruncationNotice';
4833
4915
  InfoItemsHighlight.displayName = 'InfoItemsHighlight';
4834
4916
 
4835
4917
  // All theming uses CSS variables with Design System fallbacks (Poppins).
@@ -6642,19 +6724,244 @@ function getIcon(_ref) {
6642
6724
  size: "small"
6643
6725
  }) : null;
6644
6726
  }
6645
- const renderExtractedValue = (value, keyPrefix) => {
6727
+
6728
+ /**
6729
+ * Lines a literal (code-block) render may emit, shared across ONE CATEGORY.
6730
+ *
6731
+ * Scope is per category, not per card: `ExtractedCategoryContent` creates one budget and
6732
+ * threads it through every item and leaf it renders, so a card with N literal categories can
6733
+ * emit up to N x this figure. That is deliberate — a category is the unit a reader looks at,
6734
+ * and letting one category's pathological payload blank out a sibling section would trade a
6735
+ * bounded render for a misleading one.
6736
+ *
6737
+ * Two earlier attempts were not bounds at all. A per-leaf cap failed because
6738
+ * `renderExtractedValue` recurses into arrays (5,000 one-line entries → 5,004 nodes). A
6739
+ * per-value cap failed because upstream emits one item PER CANDIDATE GROUP, so 20 groups each
6740
+ * got a fresh 50 (→ 1,000 lines, 20 notices).
6741
+ *
6742
+ * Generous for real data: a real `_dmarc` name publishes one record and the pathological
6743
+ * production case is two.
6744
+ */
6745
+ const MAX_LITERAL_LINES = 50;
6746
+
6747
+ /**
6748
+ * Characters rendered per literal line.
6749
+ *
6750
+ * Count alone does not bound size — 50 lines of 200KB each rendered 10,001,001 characters
6751
+ * (measured). 4096 rather than upstream's 512 context bound: this is the UI, and legitimate
6752
+ * evidence exceeds 512 — a DNS TXT RR concatenates multiple 255-octet strings, and a
6753
+ * 4096-bit DKIM `p=` value alone is ~740 characters. 512 would have silently truncated real
6754
+ * keys, which on a card whose job is showing published records is a correctness bug, not a
6755
+ * safety measure.
6756
+ */
6757
+ const MAX_LITERAL_LINE_CHARS = 4096;
6758
+
6759
+ /**
6760
+ * Total literal characters per CATEGORY, independent of the per-line cap.
6761
+ *
6762
+ * The per-line cap alone still allows `MAX_LITERAL_LINES × MAX_LITERAL_LINE_CHARS`. This is
6763
+ * the ceiling that actually bounds payload size. Same per-category scope as
6764
+ * `MAX_LITERAL_LINES` — see that note.
6765
+ */
6766
+ const MAX_LITERAL_TOTAL_CHARS = 64000;
6767
+
6768
+ /**
6769
+ * Array entries VISITED per category, whatever they turn out to contain.
6770
+ *
6771
+ * Distinct from the line budget, which bounds output: entries that contribute nothing —
6772
+ * empty strings, whitespace, nulls — do not consume output quota, so a scan that stopped
6773
+ * only on the output budget could still walk a million entries. Nothing past this is read.
6774
+ */
6775
+ const MAX_LITERAL_ENTRIES_SCANNED = 2000;
6776
+
6777
+ /**
6778
+ * Shared, mutable literal render budget for one CATEGORY.
6779
+ *
6780
+ * `hiddenLines` is a LOWER BOUND, not an exact count: producing an exact figure would mean
6781
+ * scanning every withheld character of an attacker-controlled value, which is unbounded work
6782
+ * for a number nobody acts on. Copy says "at least N" / "not inspected" accordingly.
6783
+ */
6784
+
6785
+ const createLiteralBudget = () => ({
6786
+ lines: MAX_LITERAL_LINES,
6787
+ chars: MAX_LITERAL_TOTAL_CHARS,
6788
+ entries: MAX_LITERAL_ENTRIES_SCANNED,
6789
+ hiddenLines: 0,
6790
+ hiddenUncounted: false,
6791
+ shortenedLines: false
6792
+ });
6793
+ const budgetExhausted = b => b.lines <= 0 || b.chars <= 0;
6794
+
6795
+ /**
6796
+ * Truncate without splitting a surrogate pair.
6797
+ *
6798
+ * A bare `slice` cuts UTF-16 code units, so a boundary landing between a high and low
6799
+ * surrogate emits a replacement glyph — a rendering artifact in what is supposed to be a
6800
+ * verbatim record.
6801
+ */
6802
+ const sliceWholeCodePoints = (value, max) => {
6803
+ if (value.length <= max) {
6804
+ return value;
6805
+ }
6806
+ const code = value.charCodeAt(max - 1);
6807
+ // High surrogate at the boundary: its pair would be cut, so drop it too.
6808
+ const end = code >= 0xd800 && code <= 0xdbff ? max - 1 : max;
6809
+ return value.slice(0, end);
6810
+ };
6811
+
6812
+ /**
6813
+ * Render an extracted value.
6814
+ *
6815
+ * `literal` is set for code-block values (DNS TXT records and similar). Those come from
6816
+ * the inspected domain's own zone, NOT from our translations, so they are attacker-
6817
+ * controlled and must never go through `parseGlossaryMarkdown` — its documented trust
6818
+ * model assumes source-controlled input, and it turns `[text](https://...)` into a real
6819
+ * anchor. See the adversarial tests in `SignalCardExtractedItems.test.tsx`.
6820
+ */
6821
+ const renderExtractedValue = function (value, keyPrefix) {
6822
+ let literal = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
6823
+ let budget = arguments.length > 3 ? arguments[3] : undefined;
6646
6824
  const nodes = [];
6825
+ // A local budget when none is shared, so non-card callers still get a bound.
6826
+ const b = budget !== null && budget !== void 0 ? budget : createLiteralBudget();
6647
6827
  const process = (input, prefix) => {
6648
6828
  if (input === null || input === undefined) {
6649
6829
  return;
6650
6830
  }
6651
6831
  if (Array.isArray(input)) {
6652
- input.forEach((entry, idx) => process(entry, `${prefix}-${idx}`));
6832
+ for (const entry of input) {
6833
+ // Stop traversing once nothing more can be emitted: continuing would walk the whole
6834
+ // attacker-controlled array to render nothing.
6835
+ if (literal && (budgetExhausted(b) || b.entries <= 0)) {
6836
+ b.hiddenUncounted = true;
6837
+ return;
6838
+ }
6839
+ if (literal) {
6840
+ b.entries -= 1;
6841
+ }
6842
+ process(entry, `${prefix}-${nodes.length}`);
6843
+ }
6653
6844
  return;
6654
6845
  }
6655
6846
  if (typeof input === 'string') {
6656
- const trimmed = input.trim();
6657
- if (!trimmed) {
6847
+ const trimmed = literal ? undefined : input.trim();
6848
+ if (!literal && !trimmed) {
6849
+ return;
6850
+ }
6851
+
6852
+ // DS-115: in literal (code-block) mode a single string leaf may itself carry multiple
6853
+ // records separated by newlines — and so may a string nested inside an array, e.g.
6854
+ // `['record A\nrecord B']`. Split every string leaf, not just a top-level one, so each
6855
+ // record gets its own <p> (and so its own CodeBlockLine) rather than flowing together.
6856
+ if (literal) {
6857
+ // Every line becomes a React element and a styled `CodeBlockLine`, and the values are
6858
+ // attacker-controlled: DNS TXT records read from the domain under inspection.
6859
+ // Upstream deliberately does NOT bound the card path (only the context/LLM path is
6860
+ // capped) because a display cap that dropped a whole name could make the
6861
+ // multiple-records predicate deny a real conflict. That is right upstream, and it
6862
+ // makes the RENDER side the place to bound node count and byte size.
6863
+ //
6864
+ // Scanned with an index-bounded loop rather than `split().map().filter().slice()`:
6865
+ // the chained form materializes every line of an adversarial value before discarding
6866
+ // almost all of it, so the work is unbounded even though the output is not. The
6867
+ // string is never `trim()`ed as a whole either — that copies the entire input.
6868
+ let cursor = 0;
6869
+ while (cursor < input.length && !budgetExhausted(b)) {
6870
+ // Bound the scan for one line's end by what could still be rendered, so a single
6871
+ // 10-million-character line is never fully walked or normalized.
6872
+ const scanLimit = Math.min(input.length, cursor + MAX_LITERAL_LINE_CHARS + 1);
6873
+ let next = cursor;
6874
+ while (next < scanLimit && input[next] !== '\n' && input[next] !== '\r') {
6875
+ next += 1;
6876
+ }
6877
+ const overlong = next === scanLimit && next < input.length && input[next] !== '\n' && input[next] !== '\r';
6878
+ const raw = input.slice(cursor, next);
6879
+ const line = raw.replace(/\s+/g, ' ').trim();
6880
+ if (overlong) {
6881
+ // Skip this line’s untruncated remainder WITHOUT materializing it — and BOUND the skip.
6882
+ // Walking to the next newline is still O(input): a 40-million-character line with no
6883
+ // newline took ~164ms against ~97ms for 20M, so the work was linear in attacker input
6884
+ // even though the OUTPUT was bounded. Give up after a bounded probe and report the
6885
+ // rest as uninspected rather than scanning it.
6886
+ const skipLimit = cursor + MAX_LITERAL_LINE_CHARS;
6887
+ while (cursor < input.length && cursor < skipLimit && input[cursor] !== '\n' && input[cursor] !== '\r') {
6888
+ cursor += 1;
6889
+ }
6890
+ if (cursor < input.length && input[cursor] !== '\n' && input[cursor] !== '\r') {
6891
+ // The skip budget ran out before this line ended. Record that the withheld
6892
+ // figure is a floor, then stop consuming budget so the reporting block below
6893
+ // still runs from this cursor — an early `break` here escaped the loop and
6894
+ // skipped reporting entirely, leaving NO notice at all (caught by test).
6895
+ b.hiddenUncounted = true;
6896
+ b.lines = 0;
6897
+ }
6898
+ } else {
6899
+ cursor = next;
6900
+ }
6901
+
6902
+ // Advance past the whole run of newline characters, matching the previous
6903
+ // `/[\r\n]+/` split so a CRLF pair does not emit a blank line between records.
6904
+ while (cursor < input.length && (input[cursor] === '\n' || input[cursor] === '\r')) {
6905
+ cursor += 1;
6906
+ }
6907
+ if (!line) {
6908
+ continue;
6909
+ }
6910
+ const allowed = Math.min(MAX_LITERAL_LINE_CHARS, b.chars);
6911
+ const shown = sliceWholeCodePoints(line, allowed);
6912
+ if (shown.length < line.length || overlong) {
6913
+ b.shortenedLines = true;
6914
+ }
6915
+ nodes.push( /*#__PURE__*/React__default.createElement("p", {
6916
+ key: `${prefix}-text-${nodes.length}`
6917
+ }, shown));
6918
+ b.lines -= 1;
6919
+ b.chars -= shown.length;
6920
+ }
6921
+
6922
+ // Report — never silently drop. This card exists to tell someone which records to
6923
+ // remove, and a hidden remainder invites them to believe they are finished.
6924
+ //
6925
+ // The count is deliberately a LOWER BOUND: an exact figure would mean scanning every
6926
+ // withheld character of an attacker-controlled value. `hiddenUncounted` is what makes
6927
+ // the copy honest about that ("at least N"), so it MUST be set whenever the scan
6928
+ // stopped early. The previous version counted SEPARATORS rather than segments and
6929
+ // could stop on its own budget while still reporting an exact-sounding figure, so 55
6930
+ // records reported 4 withheld when 5 were (verified).
6931
+ if (cursor < input.length) {
6932
+ let hidden = 0;
6933
+ let scan = cursor;
6934
+ // Count SEGMENTS: a segment is a run of non-newline characters.
6935
+ //
6936
+ // BOTH loops are bounded. The segment count is capped by the line budget, and each
6937
+ // segment's own walk is capped by `MAX_LITERAL_LINE_CHARS` — without that inner cap a
6938
+ // remainder holding one very long newline-free line walked the whole string (20M
6939
+ // chars ≈ 42ms, linear in attacker input), which silently undid the "bound WORK, not
6940
+ // only output" guarantee the skip loop above exists to provide.
6941
+ while (scan < input.length && hidden < MAX_LITERAL_LINES) {
6942
+ while (scan < input.length && (input[scan] === '\n' || input[scan] === '\r')) {
6943
+ scan += 1;
6944
+ }
6945
+ if (scan >= input.length) {
6946
+ break;
6947
+ }
6948
+ hidden += 1;
6949
+ const segmentLimit = scan + MAX_LITERAL_LINE_CHARS;
6950
+ while (scan < input.length && scan < segmentLimit && input[scan] !== '\n' && input[scan] !== '\r') {
6951
+ scan += 1;
6952
+ }
6953
+ if (scan < input.length && input[scan] !== '\n' && input[scan] !== '\r') {
6954
+ // Gave up mid-segment, so anything past here is uncounted.
6955
+ b.hiddenUncounted = true;
6956
+ break;
6957
+ }
6958
+ }
6959
+ // Anything left unscanned means the figure is a floor, not a total.
6960
+ if (scan < input.length) {
6961
+ b.hiddenUncounted = true;
6962
+ }
6963
+ b.hiddenLines += hidden;
6964
+ }
6658
6965
  return;
6659
6966
  }
6660
6967
  nodes.push( /*#__PURE__*/React__default.createElement("p", {
@@ -6664,11 +6971,37 @@ const renderExtractedValue = (value, keyPrefix) => {
6664
6971
  }
6665
6972
  if ( /*#__PURE__*/React__default.isValidElement(input)) {
6666
6973
  var _input$key;
6974
+ // Charged against the budget too: element leaves are nodes like any other, and an
6975
+ // uncharged leaf type is an escape hatch — 5,000 object leaves previously rendered
6976
+ // 5,000 lines with no notice.
6977
+ if (literal) {
6978
+ if (budgetExhausted(b)) {
6979
+ b.hiddenLines += 1;
6980
+ return;
6981
+ }
6982
+ b.lines -= 1;
6983
+ }
6667
6984
  nodes.push( /*#__PURE__*/React__default.cloneElement(input, {
6668
6985
  key: (_input$key = input.key) !== null && _input$key !== void 0 ? _input$key : `${prefix}-element`
6669
6986
  }));
6670
6987
  return;
6671
6988
  }
6989
+
6990
+ // Numbers, booleans, objects. Stringified, so they are bounded and charged exactly like
6991
+ // string leaves rather than bypassing the budget.
6992
+ if (literal) {
6993
+ if (budgetExhausted(b)) {
6994
+ b.hiddenLines += 1;
6995
+ return;
6996
+ }
6997
+ const text = sliceWholeCodePoints(String(input), Math.min(MAX_LITERAL_LINE_CHARS, b.chars));
6998
+ b.lines -= 1;
6999
+ b.chars -= text.length;
7000
+ nodes.push( /*#__PURE__*/React__default.createElement("p", {
7001
+ key: `${prefix}-fallback`
7002
+ }, text));
7003
+ return;
7004
+ }
6672
7005
  nodes.push( /*#__PURE__*/React__default.createElement("p", {
6673
7006
  key: `${prefix}-fallback`
6674
7007
  }, String(input)));
@@ -6676,6 +7009,65 @@ const renderExtractedValue = (value, keyPrefix) => {
6676
7009
  process(value, keyPrefix);
6677
7010
  return nodes;
6678
7011
  };
7012
+
7013
+ /**
7014
+ * Wrap already-literal, newline-split nodes in a `CodeBlock`, one `CodeBlockLine` per record.
7015
+ *
7016
+ * Shared by every code-block path so they cannot drift apart: `CodeBlock` sets
7017
+ * `white-space: normal`, so without one element per line multiple records flow together.
7018
+ */
7019
+ const renderCodeBlock = nodes => /*#__PURE__*/React__default.createElement(CodeBlock, null, nodes.map((node, idx) => {
7020
+ if ( /*#__PURE__*/React__default.isValidElement(node) && node.type === 'p') {
7021
+ var _node$key;
7022
+ return /*#__PURE__*/React__default.createElement(CodeBlockLine, {
7023
+ key: (_node$key = node.key) !== null && _node$key !== void 0 ? _node$key : `code-line-${idx}`
7024
+ }, node.props.children);
7025
+ }
7026
+ return node;
7027
+ }));
7028
+
7029
+ /**
7030
+ * Notice that the renderer withheld part of a literal value.
7031
+ *
7032
+ * The caps above are initial-render SAFETY limits, not a completeness guarantee: DNS defines
7033
+ * no record-count limit for a name, a TXT RR may concatenate several 255-octet strings, and
7034
+ * under RFC 9989 §4.10 every competing record is discarded — so dozens of short records are
7035
+ * protocol-plausible and each withheld value is one the operator still has to delete.
7036
+ *
7037
+ * Reporting is therefore the minimum: truncating silently would tell someone the list is
7038
+ * complete when it is not. A "copy all published records" action was built and REVERTED (see
7039
+ * CHANGELOG) — it could not honour its own promise, so full-evidence access is tracked
7040
+ * separately rather than half-delivered here.
7041
+ *
7042
+ * Rendered OUTSIDE the `CodeBlock` and as its own `LiteralTruncationNotice`, for a security
7043
+ * reason rather than a stylistic one: inside the block it was structurally and visually
7044
+ * identical to a record, so a zone publishing the literal text `[… 3 more records not shown]`
7045
+ * produced an indistinguishable line (verified). Zone content and renderer commentary must
7046
+ * not share a presentation. `data-testid` is stable so tests can assert the notice
7047
+ * specifically, rather than matching prose an attacker could also publish.
7048
+ */
7049
+ const renderTruncationNotice = (budget, t) => {
7050
+ const parts = [];
7051
+ if (budget.hiddenLines > 0) {
7052
+ parts.push(t(budget.hiddenUncounted ? 'card.extracted.truncated-records-atleast' : 'card.extracted.truncated-records', {
7053
+ count: budget.hiddenLines
7054
+ }));
7055
+ } else if (budget.hiddenUncounted) {
7056
+ parts.push(t('card.extracted.truncated-records-uninspected'));
7057
+ }
7058
+
7059
+ // Reported ALONGSIDE the hidden count, not instead of it: a payload that both shortened
7060
+ // every visible record and hid further ones must not announce only one of the two.
7061
+ if (budget.shortenedLines) {
7062
+ parts.push(t('card.extracted.truncated-record-length'));
7063
+ }
7064
+ if (!parts.length) {
7065
+ return null;
7066
+ }
7067
+ return /*#__PURE__*/React__default.createElement(LiteralTruncationNotice, {
7068
+ "data-testid": "extracted-truncation-notice"
7069
+ }, parts.join(' '));
7070
+ };
6679
7071
  const ExtractedItem = _ref2 => {
6680
7072
  let {
6681
7073
  type,
@@ -6684,7 +7076,8 @@ const ExtractedItem = _ref2 => {
6684
7076
  text,
6685
7077
  inline = true,
6686
7078
  useCodeBlock = false,
6687
- extractionBox
7079
+ extractionBox,
7080
+ literalBudget
6688
7081
  } = _ref2;
6689
7082
  // Get translated caption for extraction box
6690
7083
  const {
@@ -6695,29 +7088,25 @@ const ExtractedItem = _ref2 => {
6695
7088
  // Parse label if it's a string with glossary markdown
6696
7089
  const parsedLabel = typeof label === 'string' ? parseGlossaryMarkdown(label) : label;
6697
7090
 
6698
- // Strip newlines from text if it's a string and we're using code block
6699
- const cleanedText = useCodeBlock && typeof text === 'string' ? text.replace(/[\r\n]+/g, ' ').replace(/\s+/g, ' ').trim() : text;
6700
- const renderedText = renderExtractedValue(cleanedText, 'extracted-item-text');
7091
+ // DS-115: a code-block value may legitimately be MULTIPLE records (e.g. a domain that
7092
+ // publishes two competing `_dmarc` TXT records). Collapsing newlines into spaces ran them
7093
+ // together into one flowed line with no visible boundary, which is precisely the condition
7094
+ // the card exists to report. `renderExtractedValue`'s literal mode splits every string leaf
7095
+ // on newlines (including strings nested in arrays) so each record renders on its own line.
7096
+ const budget = literalBudget !== null && literalBudget !== void 0 ? literalBudget : createLiteralBudget();
7097
+ const renderedText = renderExtractedValue(text, 'extracted-item-text', useCodeBlock, budget);
6701
7098
  const icon = getIcon({
6702
7099
  type,
6703
7100
  theme
6704
7101
  });
6705
7102
 
6706
- // Wrap in CodeBlock if needed
6707
- const displayText = useCodeBlock ? /*#__PURE__*/React__default.createElement(CodeBlock, null, typeof renderedText === 'string' ? renderedText : /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderedText.map(node => {
6708
- // Extract text content from p tags and flatten
6709
- if ( /*#__PURE__*/React__default.isValidElement(node) && node.type === 'p') {
6710
- const children = node.props.children;
6711
-
6712
- // If children is a string, strip ALL newlines aggressively
6713
- const cleanedChildren = typeof children === 'string' ? children.replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim() : children;
6714
- return cleanedChildren;
6715
- }
6716
- return node;
6717
- }))) : renderedText;
7103
+ // Wrap in CodeBlock if needed. The truncation notice sits OUTSIDE the block — see
7104
+ // `renderTruncationNotice` on why it must never be a `CodeBlockLine`.
7105
+ const displayText = useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : renderedText;
7106
+ const hasContent = renderedText.length > 0;
6718
7107
  const content = /*#__PURE__*/React__default.createElement(SignalCardInfoBox$1, null, /*#__PURE__*/React__default.createElement(InfoItemRow, null, icon ? /*#__PURE__*/React__default.createElement(InfoItemIcon, null, icon) : null, /*#__PURE__*/React__default.createElement(InfoItemContent, null, /*#__PURE__*/React__default.createElement(InfoItemLabel, {
6719
7108
  $inline: true
6720
- }, parsedLabel), inline && renderedText.length ? displayText : null)), !inline && renderedText.length ? /*#__PURE__*/React__default.createElement(InfoItemSubtexts, null, displayText) : null);
7109
+ }, parsedLabel), inline && hasContent ? displayText : null)), !inline && hasContent ? /*#__PURE__*/React__default.createElement(InfoItemSubtexts, null, displayText) : null);
6721
7110
 
6722
7111
  // Wrap in extraction box if specified
6723
7112
  if (extractionBox) {
@@ -6727,9 +7116,12 @@ const ExtractedItem = _ref2 => {
6727
7116
  }
6728
7117
  return content;
6729
7118
  };
6730
- function renderGridItem(item, idx, t) {
7119
+ function renderGridItem(item, idx, t, budget) {
6731
7120
  const parsedLabel = typeof item.label === 'string' ? parseGlossaryMarkdown(item.label) : item.label;
6732
- const renderedText = renderExtractedValue(item.text, `grid-item-${idx}`);
7121
+ // DS-115: code-block values are attacker-controlled DNS data, so render them literally —
7122
+ // same trust model as `ExtractedItem`. Without this the grid path fed raw TXT records
7123
+ // through `parseGlossaryMarkdown`, which turns `[text](https://...)` into a real anchor.
7124
+ const renderedText = renderExtractedValue(item.text, `grid-item-${idx}`, !!item.useCodeBlock, budget);
6733
7125
  const icon = getIcon({
6734
7126
  type: item.type,
6735
7127
  theme: item.theme
@@ -6749,7 +7141,7 @@ function renderGridItem(item, idx, t) {
6749
7141
  display: 'inline-flex'
6750
7142
  }
6751
7143
  }, icon))) : null;
6752
- const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(CodeBlock, null, renderedText) : /*#__PURE__*/React__default.createElement(GridValue, null, renderedText, alignmentIcon);
7144
+ const displayValue = item.useCodeBlock ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, renderCodeBlock(renderedText), renderTruncationNotice(budget, t)) : /*#__PURE__*/React__default.createElement(GridValue, null, renderedText, alignmentIcon);
6753
7145
  return /*#__PURE__*/React__default.createElement(React__default.Fragment, {
6754
7146
  key: idx
6755
7147
  }, /*#__PURE__*/React__default.createElement(GridLabel, null, icon && !item.tooltipKey ? /*#__PURE__*/React__default.createElement("span", {
@@ -6768,6 +7160,15 @@ const ExtractedCategoryContent = _ref3 => {
6768
7160
  const {
6769
7161
  t
6770
7162
  } = useSignalCardTranslation('investigate');
7163
+ // ONE budget per category render: upstream emits one item per candidate group, so a
7164
+ // per-item budget let 20 groups render 20 x 50 lines with 20 separate notices.
7165
+ //
7166
+ // The notice is emitted per ITEM rather than once per category. Hoisting it was attempted
7167
+ // and reverted: the budget is mutated while the ITEMS render, and a parent cannot observe
7168
+ // that — `<Body/>` is only an element when the parent returns, so the notice read a pristine
7169
+ // budget and never appeared. Doing it properly means precomputing each category's preview
7170
+ // and truncation state before rendering, which is tracked separately (see CHANGELOG).
7171
+ const literalBudget = createLiteralBudget();
6771
7172
  const useGridLayout = category.categoryType === 'dmarc-domain' || category.categoryType === 'dmarc-policy' || category.categoryType === 'dkim-details';
6772
7173
  if (useGridLayout) {
6773
7174
  var _category$items, _category$items2;
@@ -6799,12 +7200,13 @@ const ExtractedCategoryContent = _ref3 => {
6799
7200
  text: text,
6800
7201
  inline: inline,
6801
7202
  useCodeBlock: useCodeBlock,
6802
- extractionBox: undefined
7203
+ extractionBox: undefined,
7204
+ literalBudget: literalBudget
6803
7205
  });
6804
7206
  }), /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
6805
7207
  $hasNonInlineItems: hasNonInlineItems,
6806
7208
  $insideCollapsible: true
6807
- }, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
7209
+ }, (_category$items5 = category.items) === null || _category$items5 === void 0 ? void 0 : _category$items5.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
6808
7210
  } else if (hasNonInlineItems) {
6809
7211
  var _category$items6, _category$items6$find, _category$items6$find2, _category$items7;
6810
7212
  // Only TXT records present
@@ -6830,7 +7232,8 @@ const ExtractedCategoryContent = _ref3 => {
6830
7232
  text: text,
6831
7233
  inline: inline,
6832
7234
  useCodeBlock: useCodeBlock,
6833
- extractionBox: undefined
7235
+ extractionBox: undefined,
7236
+ literalBudget: literalBudget
6834
7237
  });
6835
7238
  }));
6836
7239
  } else {
@@ -6841,7 +7244,7 @@ const ExtractedCategoryContent = _ref3 => {
6841
7244
  }, /*#__PURE__*/React__default.createElement(InfoItemsGrid, {
6842
7245
  $hasNonInlineItems: false,
6843
7246
  $insideCollapsible: true
6844
- }, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t))));
7247
+ }, (_category$items8 = category.items) === null || _category$items8 === void 0 ? void 0 : _category$items8.filter(item => item.inline !== false).map((item, idx) => renderGridItem(item, idx, t, literalBudget))));
6845
7248
  }
6846
7249
  }
6847
7250
 
@@ -6867,7 +7270,8 @@ const ExtractedCategoryContent = _ref3 => {
6867
7270
  text: text,
6868
7271
  inline: inline,
6869
7272
  useCodeBlock: useCodeBlock,
6870
- extractionBox: undefined
7273
+ extractionBox: undefined,
7274
+ literalBudget: literalBudget
6871
7275
  });
6872
7276
  })));
6873
7277
  };