Pivoter et enrichir
paste 9f21 a 2,143 corrélations derrière lui. Les récupérer tous enterrerait le graphique, donc un pivot négocie à la place : il annonce le décompte, refuse de dépasser un plafond et vous permet de réduire jusqu'à ce que le nombre soit celui que vous pouvez réellement regarder.
Appuyez sur P, cliquez sur paste 9f21 et lisez le panneau.
- Rien n'est demandé jusqu'à ce que vous passiez en mode Pivot. La sélection d'un nœud ne coûte aucun appel backend.
~2,143porte un tilde car c'est la revendication du fournisseur.210 fetchedest le décompte de la bibliothèque, ce n'est donc pas le cas.- Le portail refuse alors que le décompte est terminé
maxCandidates: 2000, et indique comment le soulever. - Cochez URLs et 210 revient sous le capuchon, donc Fetch s'allume tout seul.
- Ce qui arrive n'est pas le graphique. Les 210 candidats ouvrent l'onglet Bilan du quai. La toile ne bouge pas tant que vous n'ingérez pas.
- L'ingestion n'est pas sauvegardée. Une fois qu'ils atterrissent, le pied du panneau lit
420 unsaved— les 210 nœuds et les 210 arêtes qui les accompagnent — et propose Save. Les réécrire est une deuxième décision. Ce fournisseur refuse un nœud sur cinq, donc le toast litSaved 378 of 420, le nombre tombe à42et Retry envoie exactement ceux-là.
Ou ignorez le panneau : cliquez avec le bouton droit sur paste 9f21 et Pivot ▸ répertorie les mêmes fournisseurs avec les mêmes décomptes, où la sélection d'une ligne est la course. Les résultats arrivent directement alors qu'un nombre suffisant d'entre eux sont nouveaux sur le canevas, et ouvrent l'onglet Révision lorsqu'il y en a plus.
Sélectionnez Event 5f2a pour l'autre forme : Objects & attributes déclare autoIngest et autoSave, donc ses douze objets atterrissent et sont réécrits sans volet ni geste. Passive DNS est le troisième état – pas de save du tout, donc rien de ce qu'il apporte n'est jamais compté comme non enregistré.
// A **pivot** is a runnable enrichment: two functions and some metadata. `summarize`
// says cheaply what is out there, and its facets become the narrowing controls; `fetch`
// goes and gets it. What comes back are *candidates*, staged in the dock for triage and
// never on the canvas, until someone ingests them.
//
// The whole point is the node with 2,143 correlations. Fetching all of them would ruin
// the graph, so the pivot advertises the count, declares a cap it refuses to fetch past,
// and lets the analyst narrow until the number is one they can actually look at.
/** What this fake source holds, by type. They sum to 2,143; URLs alone is 210. */
const BY_TYPE = [
{ value: 'domain', label: 'Domains', count: 1800 },
{ value: 'url', label: 'URLs', count: 210 },
{ value: 'paste', label: 'Pastes', count: 95 },
{ value: 'ip', label: 'IPs', count: 38 },
]
/** The types a narrowing chose, or all of them when it chose none. */
const chosen = (narrowing) => {
const picked = Array.isArray(narrowing.type) ? narrowing.type.map(String) : []
return picked.length ? BY_TYPE.filter((t) => picked.includes(t.value)) : BY_TYPE
}
const correlations = {
id: 'correlations',
label: 'Correlations',
// Nothing correlates with an event, so the entry is absent for one.
appliesTo: (nodes) => nodes.every((node) => node.getData()?.type !== 'event'),
// Runs when Pivot mode is entered, and again whenever the origin or the narrowing
// moves. A real one would POST the ids and the narrowing to a count endpoint.
summarize: (nodes, narrowing) => ({
total: chosen(narrowing).reduce((sum, t) => sum + t.count, 0),
facets: [{
key: 'type',
label: 'Type',
type: 'multiselect',
options: BY_TYPE.map((t) => ({ label: t.label, value: t.value, count: t.count })),
}],
}),
// The real call, reached only once the advertised count is under the cap.
fetch: (nodes, narrowing) => {
const origin = nodes[0]?.id ?? 'paste-9f21'
const out = { nodes: [], edges: [] }
for (const type of chosen(narrowing)) {
// Capped at 210 for the demo's sake; a real source would return `count`.
for (let i = 0; i < Math.min(type.count, 210); i++) {
const id = `${type.value}-${i}`
out.nodes.push({ id, data: { label: `${type.value} ${i}`, type: type.value } })
out.edges.push({ from: origin, to: id })
}
}
return out
},
// Refuse to fetch while the source claims more than this. Narrowing lifts it.
maxCandidates: 2000,
// The write half. Ingest puts candidates on the canvas; this is what puts them in
// the source system, and it is the only thing that clears the panel's unsaved
// count. A real one would POST them and report what the server accepted.
//
// This one deliberately refuses every fifth node, because a partial write is the
// normal case at any scale and the retry is the interesting part: anything not
// named in `savedNodeIds` stays unsaved, and **Retry** sends exactly that.
save: ({ nodes, edges }) => {
const written = nodes.filter((_, i) => i % 5 !== 0)
const refused = nodes.length - written.length
return {
savedNodeIds: written.map((node) => node.id),
savedEdgeIds: edges.map((edge) => edge.id),
message: refused ? `${refused} refused by the server` : undefined,
}
},
}
// The other shape a pivot takes: a small, trusted result that needs no triage. It comes
// back as one container carrying its own children and lands straight on the canvas.
//
// These objects are the source's own already, so both halves are automatic: `autoIngest`
// lands them with no triage, `autoSave` writes them back with no gesture.
const expandEvent = {
id: 'event-objects',
label: 'Objects & attributes',
appliesTo: (nodes) => nodes.length === 1 && nodes[0].getData()?.type === 'event',
autoIngest: true,
autoSave: true,
save: () => true,
fetch: ([node]) => ({
nodes: [{
id: `${node.id}-objects`,
data: { label: 'Objects', type: 'container' },
children: Array.from({ length: 12 }, (_, i) => ({
id: `${node.id}-object-${i}`,
data: { label: `attribute ${i}`, type: 'attribute' },
})),
}],
edges: [{ from: node.id, to: `${node.id}-objects` }],
}),
}
// A pivot with no `save` at all. Passive DNS is somebody else's observation, not ours to
// write anywhere — so its results are *not savable*: never counted unsaved, and no Save
// offered for them. That third state is what keeps the count honest.
const resolves = {
id: 'passive-dns',
label: 'Passive DNS',
appliesTo: (nodes) => nodes.every((node) => node.getData()?.type === 'domain'),
fetch: ([node]) => ({
nodes: Array.from({ length: 6 }, (_, i) => ({
id: `198.51.100.${20 + i}`,
data: { label: `198.51.100.${20 + i}`, type: 'ip' },
})),
edges: Array.from({ length: 6 }, (_, i) => ({ from: node.id, to: `198.51.100.${20 + i}` })),
}),
}
const options = {
// Registering here rather than later is what makes the rail button correct on the
// very first paint: `UI.pivotMode` defaults to `'auto'`, and the constructor's
// pivots land before the UI is built.
pivots: [correlations, expandEvent, resolves],
UI: {
mode: 'full',
sidebar: { collapsed: true },
// Triage happens in the dock, which starts folded: a staged set unfolds it
// by itself when it arrives. The height is worth setting explicitly — the
// default share leaves the candidate table too short to read.
minimap: false,
table: { open: false },
dock: { height: 380 },
},
render: {
defaultNodeStyle: {
size: 8,
color: (node) => ({
paste: '#b48ead', domain: '#ebcb8b', ip: '#88c0d0',
url: '#a3be8c', event: '#bf616a', container: '#5e81ac',
}[node.getData()?.type] ?? '#8f9aa8'),
},
},
}// A small investigation graph: what the analyst already has. Everything else in this
// card arrives through a pivot.
const data = {
nodes: [
{ id: 'paste-9f21', data: { label: 'paste 9f21', type: 'paste' } },
{ id: 'evil.example', data: { label: 'evil.example', type: 'domain' } },
{ id: '198.51.100.7', data: { label: '198.51.100.7', type: 'ip' } },
{ id: 'event-5f2a', data: { label: 'Event 5f2a', type: 'event' } },
],
edges: [
{ from: 'paste-9f21', to: 'evil.example' },
{ from: 'evil.example', to: '198.51.100.7' },
{ from: 'evil.example', to: 'event-5f2a' },
],
}Référence complète : Pivots et enrichissement et Sauvegarde des résultats des pivots pour la moitié d'écriture sur laquelle se termine cette carte.