Skip to content

internal/redact: rule engine, allowlist, config integration #17

Description

@Saber5656

Title

internal/redact: rule engine, allowlist, config integration

Summary

Implement the redaction engine mechanics: ordered rule application with
value-group masking, allowlist exemption, user extra patterns, bounded
block masking, and the Redactor construction from config — per DESIGN.md
§8. The built-in ruleset content ships separately (17).

Context

Invariant I4 makes this engine the chokepoint between raw source content and
everything user-visible (report, LLM). It must be RE2-safe (no backtracking
DoS from adversarial history lines), deterministic, and cheap enough to run
twice (aggregation stage + LLM prompt defense-in-depth).

Scope

  • internal/redact/redact.go, rule.go, tests. Rules content minimal
    here (two placeholder rules for tests); real corpus in 17.

Detailed Requirements

  1. Types:

    type Rule struct {
        ID       string         // stable, kebab-case (public API for allowlist docs)
        Pattern  *regexp.Regexp // RE2
        Group    int            // 0 = mask whole match; N = mask only group N
        Block    bool           // multiline block rule (see 4)
    }
    type Redactor struct { /* rules, allowlist, counters */ }
  2. New(rules []Rule, allowlist []*regexp.Regexp, extra []*regexp.Regexp) (*Redactor, error) — validates: unique ids, Group within pattern's group
    count. FromConfig(cfg config.Redaction, builtin []Rule) compiles
    extra_patterns (ids extra-1…, Group 0) and allowlist; when
    cfg.Mode == "off" returns a no-op Redactor whose Enabled() == false.

  3. Redact(s string) (string, int):

    • Apply rules in slice order. For each match: if any allowlist regex
      matches the entire matched text (group-0 span), skip masking that
      match.
    • Replacement: group-targeted splice [REDACTED:<id>] replacing only the
      group span (regexp FindAllStringSubmatchIndex + manual rebuild —
      ReplaceAllString cannot target groups).
    • Count total maskings; deterministic single pass per rule (no re-scan of
      already-masked output for the same rule; later rules do scan the
      partially-masked string — order matters and is fixed by the rule
      slice).
  4. Block rules (Block: true, e.g. private-key blocks): pattern matches the
    opening line; masking extends from the opening match through the matching
    -----END …----- line or at most 100 lines, whichever first
    (DESIGN §8), replaced by one [REDACTED:<id>].

  5. RedactEvent(e model.Event) (model.Event, int) helper — applies
    Redact to Title, Body, Project, and every Meta value; returns the
    modified copy and the total masking count across all fields (used by 18;
    LLM (24) uses plain Redact on the final prompt string).

  6. Performance guard: engine must process a 1 MiB adversarial string
    (aaaa… + near-miss token prefixes) in < 100ms with the 17 ruleset —
    benchmark included (BenchmarkRedactWorstCase), asserted loosely in a
    test (< 1s) to catch accidental catastrophic patterns.

  7. No logging of matched content anywhere (the engine must never leak what
    it masked — code-review AC).

Acceptance Criteria

  • Group masking: rule with Group=2 masks only the value span, preserving
    prefix/suffix (table test with env-var style placeholder rule).
  • Whole-match masking (Group=0) verified.
  • Allowlist: exact-match exemption works; non-matching allowlist leaves
    masking intact; allowlist never adds content.
  • Block rule: 3-line PEM-style fixture collapses to one token; runaway
    block without END masks exactly 100 lines then stops.
  • extra_patterns masked with extra-N ids; mode="off" returns input
    verbatim with count 0 and Enabled()==false.
  • Idempotence: Redact(Redact(x)) == Redact(x) over the test corpus
    (replacement tokens must not re-match any rule — asserted).
  • Multiple matches of multiple rules in one string all masked; overlap
    resolution: earlier-rule match wins, later rules see the masked text
    (asserted with crafted overlap).
  • Benchmark exists; worst-case test < 1s.
  • ≥ 95% coverage; stdlib-only.

Validation

go test -race -cover ./internal/redact/ + go test -bench RedactWorstCase -benchtime 1x output in PR.

Dependencies

01, 05 (config types), 03 (Event for the helper).

Non-goals

The real ruleset + corpus (17), entropy detection (v2, ADR-004), masking in
parsers (layering: parsers stay raw).

Design References

  • docs/DESIGN.md §8, §1.2 (I4)
  • docs/decisions/ADR-004-redaction-default-on.md

Source of truth: docs/issues/16-redaction-engine.md (PR #1, branch docs/v1-design). If this issue and the repo docs disagree, the docs win. Execution order and dependencies: docs/ISSUE_PLAN.md (this is issue 16 of 33).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions