Threat-Actor Attribution

For every malicious package the VDB ingests, a shared engine resolves the malware author — not just the victim package — and links operators across accounts via reused public keys.

Malicious-package advisories arrive two ways: the 16 registry scanners detect malware first-hand by running the malware-detection engine over a package’s source, and five advisory-aggregation sources import packages others have already flagged. After either kind commits an advisory, an inline pass resolves the responsible threat actor from the package/repo identity plus upstream registry, GitHub, and Docker Hub lookups, then records the actor, its harvested keys, and the source→actor edge. A package’s declared repository is deliberately distrusted: in dependency-confusion and typosquat attacks it points at a legitimate victim org, never the attacker.

The detected/extracted indicators of compromise — malicious domains, IPs and C2/exfil URLs — are retained (MalwareIoc, and host infrastructure in MalwareHost) and re-published as the public known-bad STIX feed, which the registry scanners then match future packages against.

Advisory-aggregation sources

Beyond the registry scanners, five aggregation sources store packages others have flagged and feed the engine. Each owns a distinct detection rule:

ProcessorsourceMalware detection rule
osm-jsonosmEvery OSM record is malicious
oss-malware-gitoss-malicious-packagesMAL- advisory files
gemnasium-gitgitlabCWE-506, or “malware”/“malicious” in title/description
ghsa-rssgithub“Malware in” title, CWE-506, or MAL- alias
snyk-fetchsnykvuln.Malicious, or title == "Malicious Package"

The general-purpose OSV pipelines (osv-json and the OSV-shaped git sources) skip attribution — they ingest advisories of all kinds and do not assume maliciousness.

Attribution model

The engine resolves an attribution form from the shape of the report, then assigns a confidence:

FormBasisConfidence
Repository reportRepo ownerHigh
Container (Docker Hub)Namespace ownerHigh
Whole-package, non-hijackRegistry maintainerHigh if tagged typosquat / dependency-confusion, else medium
Go moduleRepo owner derived from the module pathHigh
Commit hash presentCommit authorHigh
Hijack (compromised account)Maintainer is the victim — not attributed

Hijack victims are excluded

When a report describes a compromised-account hijack, the package maintainer is the victim, not the attacker. The engine records actorAttributionStatus = hijack-victim-excluded and emits no actor edge. Attributing the victim would poison the actor graph with innocent maintainers.

The impersonation trap

For non-Go packages, the repository a package declares (in its manifest, homepage, or metadata) is very often the target of a dependency-confusion or typosquat attack — a legitimate org’s repo such as microsoft, named precisely to look trustworthy. The engine never attributes the declared repo as the actor. Instead it captures it as a claimedRepo* record (URL, owner, platform) and an attribution status, so the trap is visible without contaminating the actor graph.

Go modules are the exception: a Go module’s identity is its repository path, so the repo owner is a legitimate attribution basis rather than a claimed-repo trap.

Cross-account key linkage

When the resolved actor is a GitHub identity, the engine harvests that account’s public keys:

  • SSH authentication keys and SSH signing keys — stored with their OpenSSH SHA256 fingerprint.
  • GPG keys — stored with their key id.

Each key becomes a ThreatActorKey row. A fingerprint observed across multiple accounts is the strongest available signal that those accounts are the same operator — distinct GitHub handles that authenticate or sign with the same private key collapse into one operator behind the scenes.

Schema

All columns are camelCase; all timestamps are BigInt milliseconds (time.Now().UnixMilli()).

Shared actor tables

These two tables are generic and shared across every source.

ThreatActor

ColumnNotes
uuidPrimary key
platforme.g. github, npm, pypi, dockerhub
identifierAccount/namespace identifier on that platform
actorTypeResolved actor classification
displayName, email, profileUrl, company, locationIdentity profile (best-effort)
rawJSONFull upstream identity payload
threatCountNumber of attributed threats
firstSeen, lastSeenBigInt ms

@@unique(platform, identifier) — one row per identity per platform.

ThreatActorKey

ColumnNotes
threatActorUuidFK → ThreatActor
keyTypessh-auth, ssh-signing, or gpg
githubKeyIdUpstream GitHub key id
publicKeyRaw public key material
fingerprintOpenSSH SHA256 fingerprint (SSH) or key id (GPG) — the cross-account join column
title, emailsKey metadata

Source→actor edges (exactly one per record)

A given (cveId, source) is attributed through exactly one edge table — there is no double-write. OSM uses OsmThreatActor; every other malware source uses MalwareThreatActor.

OsmThreatActor (OSM only)

ColumnNotes
osmThreatUuidFK → OsmThreat
threatActorUuidFK → ThreatActor
cveIdThe minted OSM-YYYY-N id

The impersonation-trap and hijack-status fields live on the OSM record itself: OsmThreat.actorAttributionStatus and OsmThreat.claimedRepoUrl / claimedRepoOwner / claimedRepoPlatform.

MalwareThreatActor (every non-OSM malware source)

ColumnNotes
cveId, cveSourceThe attributed advisory
threatActorUuidFK → ThreatActor
attributionFormResolved form (repository, container, whole-package, go-module, commit)
attributionBasisWhy this actor was chosen
confidencehigh / medium
commitHashSet when the form is commit-based
repoUrlAttributed repository, where applicable

MalwareAttribution (every non-OSM malware source)

Carries the trap and status for non-OSM sources, mirroring the columns OSM keeps on OsmThreat:

ColumnNotes
cveId, cveSourceThe advisory
statuse.g. hijack-victim-excluded
claimedRepoUrl, claimedRepoOwner, claimedRepoPlatformImpersonation-trap target — never attributed

Threat-intel / IOC tables

The indicators extracted from each malicious package, and the malicious host infrastructure they point at, are retained for re-publication as the public known-bad STIX feed — which the registry scanners then match future packages against. These tables are shared across every non-OSM malware source (OSM keeps its own OsmThreatIoc, below).

MalwareIoc

ColumnNotes
cveId, cveSourceThe advisory the indicator was extracted from
iocTypeIOC type (see the list below)
valueThe indicator itself — domain, URL, IP, hash, install command, …
ecosystemPackage ecosystem (nullable)
truncatedWhether the stored value was truncated
referencesJSON array — now also carrying the matched file/line plus STIX provenance for known-bad-feed matches

@@id(cveId, cveSource, iocType, value) — one row per distinct indicator per advisory.

IOC types: domain, url, ipv4, ipv6, file-hash, install-command, exfil-endpoint, email, wallet, package-name, typosquat, ownership-change. Hash formats sha256 / sha1 / md5 / sha512 / b2 are stored as file-hash variants.

Malicious C2 / exfil host infrastructure — the domains and IPs packages phone home to — is kept separately from per-advisory IOCs (in MalwareHost / MalwareHostLink) so the same host can be tied to many advisories. malscan-stix-processor publishes MalwareHost as the STIX dns feed.

MalwareHost

One row per distinct malicious host, carrying the STIX indicator it is published as.

ColumnNotes
uuidPrimary key
valueThe malicious host — a domain name or an ipv4 / ipv6 literal
hostTypeMalwareHostType enum: domain, ipv4, or ipv6
stixIndicatorId, stixPatternThe STIX indicator id and pattern this host is published as
validFrom, lastSeenBigInt ms
maxSeverityHighest severity observed across linked advisories
truncatedWhether the stored value was truncated

Each edge ties a MalwareHost to the advisory/IOC observation that surfaced it, so one host can be attributed to many packages.

ColumnNotes
malwareHostUuidFK → MalwareHost
sourceMalwareHostSource enum (which pipeline observed it)
cveId, cveSourceThe advisory the host was tied to (or osmThreatUuid for OSM)
iocTypeThe IOC type that produced the host (domain / ipv4 / ipv6)
ecosystem, severityPer-observation ecosystem and severity
firstSeenBigInt ms
linkKeyDedup key — ON CONFLICT ("linkKey") DO NOTHING

OSM retention tables

OSM additionally retains its full upstream payload and parsed IOCs (OSM-only):

TablePurposeKey columns
OsmThreatFull OSM API record retentionosmThreatUuid, cveId, packageName, registry, severity, rawJSON, actorAttributionStatus, claimedRepo*, firstSeen, lastSeen
OsmThreatIocParsed indicators of compromiseosmThreatUuid (FK), iocType, value, truncated

Execution

Attribution runs in two places:

  • Inline post-commit pass — each of the five malware processors runs attribution immediately after committing an advisory. It is first-time-only: a record that already carries a MalwareAttribution (or, for OSM, an OsmThreat attribution) row is never re-enriched.
  • One-time backfillcmd/malware-actor-backfill (just go-malware-actor-backfill) selects every record where isMaliciousPackage = true AND source <> 'osm' across all sources and attributes the historical backlog through the same engine.

v2 API exposure (Pro-gated)

Attribution is surfaced through two vdb-api v2 endpoints, gated by ProFilter:

  • GET /v2/malware/{ecosystem}/{package}
  • GET /v2/vuln/{identifier}/threat-actors

Both return advisories and counts to all callers. Paid (Pro+) callers additionally receive:

  • threatActors — including each actor’s harvested key fingerprints
  • iocs — parsed indicators of compromise
  • impersonationTrap — the captured claimedRepo* target

Community responses null those three fields and list them under _pro_data so callers can see what an upgrade unlocks.