Skip to content

IOC Federation

An IOC (Indicator of Compromise) list is a versioned inventory of known-bad artifacts: confirmed-malicious domains, file hashes, and package names. It sits outside the heuristic score. When a PKGBUILD matches an IOC entry, TrustSight emits a definitive, attributed finding ("this source domain is on emiliano-go's known-bad list, incident atomic-arch-2026-06") with no score, no band, and no probability language. This is detection, not inference.

The design rests on four principles:

  • State, not rules. IOCs are observations about the world, so they live in the baseline layer next to the corpus baseline (A13/A13b), never in the rule config. A rule override or weight cannot reach them.
  • Attribution, not aggregation. Every match names the curator that flagged the artifact. The user sees who called it bad, and can follow the incident and evidence.
  • Federation, not centralization. Independent curators publish signed baselines; TrustSight verifies, imports, and reports with source labels.
  • Expiration, not permanence. IOCs carry a time-to-live. An expired indicator is reported as expired, never silently ignored and never permanently flagged.

The baseline format

A baseline is a directory:

ioc-baseline/
├── manifest.json
└── iocs.jsonl

manifest.json

{
  "version": 1,
  "source": "emiliano-go",
  "created_at": "2026-08-09T00:00:00Z",
  "expires_at": "",
  "signature": "<hex ed25519 signature, or empty>",
  "public_key": "<hex ed25519 public key, or empty>"
}

iocs.jsonl

One JSON object per line:

{"type": "domain", "value": "evil-cdn.xyz", "source": "emiliano-go", "confidence": "high", "provenance": "ASA-2026-06", "campaign": "atomic-arch-2026-06", "expires_at": "2026-09-15T00:00:00Z"}
{"type": "hash", "value": "deadbeef...", "source": "emiliano-go", "confidence": "high", "provenance": "vendor report"}
{"type": "package", "value": "malicious-aur-pkg", "source": "emiliano-go", "provenance": "ASA-2026-06"}
{"type": "file_path", "value": "src/hooks/deps", "source": "emiliano-go", "confidence": "high", "provenance": "Sonatype-2026-003775"}
{"type": "pkgbuild_pattern", "value": "\\bnpm\\s+install\\b", "source": "emiliano-go", "confidence": "high", "provenance": "aur-general"}

Any entry with an unknown type, an unusable value, or no source is dropped with a warning; the rest import.

Indicator types and normalization

Type Match target Normalization
domain Host of a source= URL (or any referenced host) Lowercased, IDNA/punycode-folded, reduced to the registered domain, so a subdomain and the xn-- and unicode spellings all collapse to one value.
hash Digests inside sha256sums / sha512sums / md5sums (and any other hex digest in the visible text) Lowercased; must be a hex digest of a known length (32/40/56/64/96/128).
package pkgname / pkgbase and declared dependency names Lowercased, exact match.
file_path A file the diff added or modified, and a file the recipe names (install=, local source=() entries) Separators folded to /, a leading ./ and trailing / removed. Case-sensitive. Removals are not a compromise signal and do not match.
pkgbuild_pattern Added diff lines, matched as a regular expression The pattern text, compiled once. Invalid or catastrophic-backtracking patterns are refused at load.

file_path and pkgbuild_pattern are the two non-equality types. file_path is still an exact comparison on a normalized path. pkgbuild_pattern is the one type that matches a shape rather than an artefact, so it is treated as a weaker claim: a pattern finding is never FATAL - a confirmed tier is capped at CRITICAL - and an unsafe pattern is refused rather than run. The corpus pivot understands domain, hash and package only; the other two describe a file or a line, which a stored corpus row does not reference.

Pattern safety and caps

A signed baseline is third-party text, so pkgbuild_pattern entries are checked statically at load - never by running the pattern on adversarial input, which is exactly what hangs on the shapes being rejected:

  • A nested quantifier ((a+)+) or a quantified alternation ((a|aa)+) is refused; both are the classic catastrophic-backtracking shapes, and neither is anything a curator needs.
  • An invalid regex, an empty pattern, or one longer than 2,000 characters is refused.
  • At most 500 pattern entries load; the rest are ignored, so an unbounded set cannot become a per-line denial of service.
  • A refused pattern is dropped with a warning, and the analysis marks itself degraded rather than matching a smaller list than the operator believes is loaded.

Signature

signature is a detached Ed25519 signature over the canonical manifest.json (with the signature field emptied) concatenated with iocs.jsonl byte-for-byte. Verification uses the public_key carried in the manifest; IOC curator keys are not pinned in configuration. An unsigned baseline imports only with --allow-unsigned, which is for a baseline you built and trust locally.


How matching works

A dedicated IOC Match stage runs after rule matching and before scoring:

Parse -> Tokenize -> Rule Match -> IOC Match -> Score -> Verdict

It reads added diff lines and the current package text used for package-level checks, then queries the stored indicators for every source-URL host, every checksum and hex digest, and the package/pkgbase/dependency names. Hits are attached to PackageFact.ioc_matches; see the report schema.

Each IocMatch carries type, value, source, confidence, provenance, campaign, the surface it was found on (source_host, checksum_array, package_name, a dependency field, ...), the line, and expired.

What an IOC match does not do

  • It never changes the score. Matches live on ioc_matches, never in score_breakdown; the same PKGBUILD scores identically with or without a hit. IOCs are attribution the reviewer can verify, not a number.
  • It is not downgraded by a coverage gap, positive evidence, or an override. If an indicator matched in the analysed text, it is reported.
  • It does not disappear when it expires. An expired indicator is still reported, labelled expired, so a lapsed indicator never reads as clean. Only trustsight ioc list hides expired entries by default (pass --include-expired).
  • A cleaned-up package name is not an indicator. A package entry flags the name forever, a false positive the moment the malicious commit is reverted. Compromised-but-remediated names live in a separate point-in-time exposure reference under data/aur-compromised/, which H056 never matches.

These properties are enforced by the gates an IOC match carries its source, IOC matches never contribute to the score, an expired IOC is never silent, IOCs are not in the rule config layer, and a pattern IOC that risks ReDoS is refused (see the enforcement map).


Configuration and commands

The [baselines.ioc] section and its feeds are documented in configuration. The trustsight ioc {sources,import,update,list,export} commands are documented in the CLI reference.

Storage is the ioc_entries table, keyed by (type, value, source). Importing a baseline for a source replaces that source's rows and leaves other sources (and expired rows) untouched, so imports are idempotent and attributable.


Harvesting

Turning advisories and community reports into signed baselines is the job of a separate project, not TrustSight's codebase: a harvester proposes candidate entries, human curators review and sign, and TrustSight only ever consumes the signed output. Keeping the harvester out of the tool is what keeps the IOC gate non-evadable by construction: a hash either matches or it does not, and no code in TrustSight decides what belongs on the list.

Federation

The indicator tier (I-series) formalizes what this page describes: signed external data as a first-class tier, matched outside the heuristic score and reported at maximum alert priority. The Indicators reference documents the tier and H056.

A federated source is a publisher's signed indicator set, fetched with ioc update --federation <url> --publisher-key KEY.pem. The set is verified against the pinned publisher key through the same signature-verification path as the release channel before any entry is imported, so an unsigned or mis-signed set is refused, never merged. Publisher trust is per key and revocable; only hashed indicators travel (domains, hashes, names - no raw maintainer data). The report already exposes the promoted indicators array (see the report schema), so a consumer needs no change.

Cadence. ioc update refreshes the release-channel baseline and ioc update --federation refreshes a publisher set, both on the operator's cadence - typically the same schedule as the corpus cycle. A stale or unsigned baseline is refused rather than merged; the tool never falls back to an older signed set silently. A fresh install ships an empty list, so a miss is uninformative until the operator imports a signed set.