OSM Manager

OSM Manager

/resolve/osm-manager, admin only. Three tabs, because there are three distinct jobs, and two of them write to a public database under our name.

Everything OSM receives from us is published and reviewed in public. Their reporting guidelines name the failure modes that get a report rejected:

lack evidence or proof · report legitimate packages as malicious without substantiation · provide vague descriptions · submit duplicates without database verification · exaggerate severity levels

Four of those five are decided before the request is sent. That is why the validation in vdb-site/api/internal/osm is not defensive plumbing but the quality gate itself, and why it runs on every report regardless of whether a human or the mapper produced it.


Tab 1 — OSM Records

CVEMetadata.source = 'osm' left-joined to OsmThreat, paginated, with filters read from the data rather than hardcoded.

The join is left on purpose. OsmThreat is written by submission and by the threat feed, so an advisory can exist with no threat row and a threat row can outlive its advisory. Both of those are what a reviewer is looking for, and an inner join would hide both. The linked filter selects either side.

Tab 2 — Lookup

GET /functions/v1/check-malicious, answered beside whatever our own corpus holds for the same resource.

Both answers come back in one response because the useful information is the combination, not either half:

OSMUsConclusionAction
yesnothey hold it, we do notrecord their threat against one of our advisories
yesyesboth hold itreconcile; nothing to submit
noyeswe hold it, they do nota submission candidate
nononeithera negative answer, not a clean bill of health

The conclusion is named server-side (suggestedAction) so the lookup tab and the submit tab cannot derive it differently.

Our side matches on OsmThreat identity and on MalwareIoc.value. The second is what makes the tab useful: a package our own scanners flagged has an IOC row and no threat row at all, and that combination is exactly the candidate the third tab exists for.

Tab 3 — Submit

Our malicious-package records that OSM does not already hold, each shown as the exact body that would be sent, with every validation problem against it.

Scope defaults to our own detections. Of the ~254k records with a malicious indicator and no OSM threat, ~227k carry cveSource = 'oss-malicious-packages' — the OpenSSF feed, a public third-party corpus we ingest. Relaying somebody else’s dataset in bulk is not research; it is noise on a public database with our name on it. osm is excluded from every scope, since submitting OSM’s own data back to OSM is duplication by construction.

Within the remaining sources, CVEMetadata.isMaliciousPackage is the gate. A registry slug is not one: pypi and go are shared with the PyPA and Go-vulndb advisory importers, so filtering on source alone would offer ordinary advisories as malware reports.

Order of operations on submit

  1. Validate against the OSM requirements. A blocking problem stops here, before any network call.
  2. Check for duplicates with check-malicious. A hit stops here and returns what OSM already holds. This cannot be skipped — the request field exists only so the name is documented, and passing it is a 400.
  3. Submit POST /functions/v1/submit-threat-report.
  4. Record the result graph: OsmThreat + OsmThreatIoc + OsmThreatActor, idempotent on every table.

Step 4 failing after step 3 succeeded is reported as its own outcome, not as a generic error. The report is published at that point, and a reviewer who retried would submit it twice.


What the mapper does differently from osm-submitter

The desktop osm-submitter has a review queue over the same tables. Three of its mappings produce reports OSM would have to correct or reject, and the Go mapper deliberately does not reproduce them:

Fieldosm-submitterHereWhy
resource_identifier (package)npm/left-padleft-padThe documented format is the registry package name; the registry has its own field. A slash-prefixed identifier matches nothing on their side.
registryfalls back to CVEMetadata.sourceonly the four documented values, else omittedOtherwise we publish registry="vulnetix" or registry="ghsa". The field is optional, so an unmappable ecosystem is carried in the tags and the description instead — the information survives and the enum stays valid.
payload_descriptionraw IOC valuesdefangedOSM asks for hxxps:// and [.]. A live C2 URL on a public page is a link a reader can click.

version_info accepts comma-separated versions only, so entries carrying a range operator are dropped rather than sent, and Validate refuses the field if one survives.

Configuration

VariableWherePurpose
OSM_API_TOKENprod/vdb-manager/api-keys, wired into the vdb-site-api task definitionBearer token. Server-side only; the console calls our endpoints and never sees it.
OSM_API_BASEunset in productionPoints the client at a mock for testing.

The token is shared with the processors rather than copied into a second secret, so the OSM Manager and the ingest pipeline can never authenticate as different reporters.

GET /admin/osm/config reports whether a token is present — never the token — so a form that cannot submit says so up front instead of failing on the button.

Endpoints

All under /api/site/v1/admin/osm, all behind RequireVulnetixAdmin.

MethodPathPurpose
GET/configToken presence and the vocabularies the forms offer
GET/facetsDistinct filter values, read from the data
GET/recordsThe review list
GET/records/{cveId}One record with its IOCs and actors
POST/recordsRecord an OSM threat against one of our advisories
POST/checkProxy check-malicious, plus our own matches
GET/candidatesSubmission queue, each with its report and problems
POST/submitValidate, dedupe, submit, record

/check is a POST for a read because the resource identifier can be a full URL; putting live indicators in our own query strings invites truncation and logging.

Tests

  • internal/osm/osm_test.go — the contract: identifier formats per report type, the registry enum, defanging (including idempotency and not mangling filenames), version normalisation, and the rejection of vague descriptions.
  • internal/handler/osm_manager_sql_test.go (-tags=integration) — every query against a real Postgres, in every scope and filter shape. An empty candidate queue looks exactly like “nothing to submit”, which is the one wrong answer nobody would investigate.