Vulkro Red
A finding is a dot. An attacker draws the line.
The review hands you a list. Vulkro Red is the other half of the team: the red team that goes on the offensive against what the review found, and tells you which items on that list actually chain into a break-in. It reads a checkout, takes the facts the engine already holds, and puts them in attacker order: reach a surface, defeat whatever guards it, use the flaw, turn it into harm. It is static and offline throughout. It reads source, not traffic, so no request is sent, no live server is touched and nothing is executed. Every stage of a chain cites a real file and line in your own code, or it is not printed at all.
- Reads a checkout, not a server
- No request is ever sent
- Every stage cites a file and a line
- The model narrates, the engine decides
$ vulkro-red attack ./checkout --report chains.html # illustrative output offline static reconstruction -------------------------------- chain 01 data exfiltration 6 stages trust 78/100 strongly grounded (the honest ceiling for a static read) 01 recon src/routes/index.ts:31 02 initial access src/routes/invoice.ts:47 03 protection bypass src/app.ts:88 ... 06 exfiltration src/serializers/invoice.ts:64 no request sent. nothing executed.
Coming soonVulkro Red is not released yet, so it is not part of the trial today. Everything on this page describes what it does and how it works. The trial covers the review, across your codebase and your Salesforce org.
01 / The gap
Severity is per finding. Risk is per path.
A findings list scores every defect on its own, which is the only fair way to score one defect. It is the wrong way to read three. Here are three findings on a single handler. Individually they are the kind of row a team defers: a middleware ordering note, a missing predicate, a chatty serializer. Read in attacker order they are one path from an anonymous request to another customer's record.
| Severity | Finding | CWE | Rule |
|---|---|---|---|
| MED | Auth middleware is mounted after the invoice routersrc/app.ts:88 | CWE-284 | VULK-1180 |
| MED | Record lookup carries no owner or tenant predicatesrc/services/invoice.ts:120 | CWE-639 | VULK-1305 |
| LOW | Serializer returns the full customer recordsrc/serializers/invoice.ts:64 | CWE-200 | VULK-1544 |
The same three, in attacker order
- 01Anyone can reach the route. The guard is real and it is correct. It is mounted after this router, so it never runs on it.
src/app.ts:88 - 02Any id resolves. The lookup matches on the identifier alone. There is no owner or tenant predicate to fail.
src/services/invoice.ts:120 - 03The response is the whole record. The serializer returns every customer field it holds, including the billing address.
src/serializers/invoice.ts:64
One path: an unauthenticated request with a guessed identifier returns another customer's record, billing address included. None of the three findings says that. The order does.
This is the gap a list leaves. Each finding was reported honestly and ranked honestly. The risk lives in the composition, and the composition is exactly what nobody has time to do by hand across a few hundred findings. It is also the division of labour between the two halves of the team: the review finds the defects and scores each one on its own merits, and the red team says which of them stop being separate defects the moment they are read in order.
02 / The chain
Every stage cites a file and a line, or it is not printed.
That rule is the whole design. A chain is composed from facts the deterministic engine already holds: the routes your framework declares and the auth tier on each, the source-to-sink flows and whether a sanitiser sits on the path, and the findings themselves. Where a step has nothing to cite, the step is not emitted, so the report never asserts something it cannot show you in your own source.
- 01recon214 routes inventoried from the router table, 18 with no guard
src/routes/index.ts:31VULK-0311 - 02initial accessInvoice read route is reachable without a session
src/routes/invoice.ts:47VULK-1042CWE-306 - 03protection bypassrequireAuth is mounted after the invoice router, so it never runs
src/app.ts:88VULK-1180CWE-284 - 04injectionPath parameter reaches the query builder without a bound parameter
src/db/invoice.repo.ts:126VULK-2207CWE-89 - 05broken accessLookup carries no owner or tenant predicate, so any id resolves
src/services/invoice.service.ts:212VULK-1305CWE-639 - 06exfiltrationSerializer returns 12 customer fields, including billing address
src/serializers/invoice.ts:64VULK-1544CWE-200CRIT
- +30route resolved from the framework router table
- +24guard absent on this path (checked at src/app.ts:88)
- +18sink reaches a column set tagged as customer data
- +12every stage cites a parsed file and line
- -6no runtime evidence: nothing was executed
noteStatic analysis. No request was sent and no exploit was executed.
The trust score under the chain is not a mood. It is the sum of the signals that stacked behind it, printed with their weights, so you can see what the number is made of before you decide whether to spend an afternoon on it.
The structure belongs to the engine
Ranked, not just listed
One objective per chain
Dataflow crosses file boundaries up to four hops in Python, JavaScript, TypeScript and Go. On Java it is same-file only, so a Java chain is grounded inside a single file rather than across several.
03 / Corroboration
A high severity is one signal. One signal is not a chain.
Trust is earned by agreement between things that were measured independently: the severity of the finding, the engine's own confidence in it, whether the entry point is reachable by an attacker, whether dataflow proved the untrusted source actually reaches the sink, and whether a protection gap sits on that flow. Where they agree, the chain leads the report. Where only one of them fires, it does not.
- 01initial accessAccount export handler sits in a request-handler file
src/admin/export.ts:52VULK-1042 - 02exfiltrationHandler builds a CSV of account rows
src/admin/export.ts:96VULK-1544CWE-200HIGH
- +25severity high, as the engine reported it
- +5engine confidence medium, not high
- +10in a request-handler file, but no route resolved to it
- +5a concrete objective: data exfiltration
- 0dataflow did not prove an untrusted source reaches the sink
- 0no protection gap found on the flow
noteStatic analysis. No request was sent and no exploit was executed.
The signals that scored nothing are printed too, and that is deliberate. A confidence label hides what is missing. A list of weights shows it: here, dataflow never proved the source reaches the sink, and no route resolved to the handler. Those two absences are the reason a genuinely high-severity finding is not being called a path.
04 / The ceiling
There is no proven-exploit tier, because there is no honest way to earn one.
Proving an exploit takes a live oracle: a request sent at a running target and a response that shows the attack worked. Vulkro Red sends nothing, so it cannot earn that tier. Rather than approximate it with a strong-sounding word, the tier is absent. The ceiling is a strongly-grounded static path, and the report states that in its own header rather than in a footnote nobody reads.
- Proven exploitnot offeredNot a tier here, and never printed. Earning it would take a request sent at a running target and a response showing the attack worked. Vulkro Red sends none.
- Strongly groundedThe ceiling. Several independent signals agree, and every stage cites a file and a line in your own checkout. Read it as a path worth closing, not as a thing that has happened.
- Needs human traceThe path is cited and plausible, but the signals did not corroborate each other. It goes to triage rather than to the top of the report, and it is never dropped for being inconvenient.
The word confirmed does not appear in the report. That is a position, not a shortfall being worked around. A tool that prints confirmed without having confirmed anything teaches the person reading it to stop checking, and the day it is wrong is the day they have lost the habit that would have caught it. A path you can open in your editor and follow line by line is a stronger thing to hand an engineer than a verdict they have to take on faith.
Where you do need an exploit executed against a running system, that is a live penetration test. It is a different exercise, with different authorization, and this is not a substitute for it.
05 / Supply chain
Code you never read runs on every machine that installs it.
The same chain synthesis runs over what the engine found in manifests, lockfiles and CI workflow files, because the shortest path into an application is often not through the application. It reads what is in the repository and the local CVE bundle, and it never contacts a package registry.
Install hooks
Poisoned pipeline steps
Dependency confusion
Trojan source
A vulnerable dependency and a hostile one are not the same thing
The industry blurs these two under one word and the blur costs teams their weekends. A known-vulnerable dependency is a package carrying a published advisory against the version you resolved: it has a defect, and the answer is usually an upgrade. A hostile dependency is a package doing something to you on purpose, and the answer is an incident. Vulkro Red keeps them apart, and it keeps a third line as well: a vulnerable dependency that is merely present is posture and is listed as posture, while one your code actually reaches is a stage in a chain.
- CRITorg.apache.commons:commons-text@1.9MavenCVE-2022-42889reachable
pom.xmlStringSubstitutor.replace called from ReportController.render at src/main/java/com/acme/report/ReportController.java:88
- HIGHlodash@4.17.20npmCVE-2021-23337GHSA-35jh-r3h4-6jhmreachable
package-lock.jsontemplate() called from buildInvoice at routes/invoice.ts:47, two hops from the POST /invoices route
- MEDurllib3@1.26.4PyPICVE-2021-33503unknown
poetry.lockadvisory declares no vulnerable symbols, so no reachability claim is made and the severity is left as matched
- INFOgithub.com/gin-gonic/gin@1.7.7GoCVE-2023-29401unreachable
go.sumContext.FileAttachment is not called from the forward closure of any route, main, or plugin hookdowngraded from medium
- INFOtime@0.1.44crates.ioCVE-2020-26235unreachable
Cargo.lockpulled in transitively by chrono; no project function in the forward closure calls the affected symboldowngraded from high
Unreachable findings are downgraded, not dropped. Severity falls to Info, the row keeps its call-graph reason, and it stays in the report and in the JSON. A call site missed through dynamic dispatch shows up as down-ranked rather than as absent.
Manifests and lockfiles read: npm, PyPI, Go modules, crates.io, Maven. The default published CVE bundle currently carries npm and PyPI, so matching on the other three depends on the wider bundle. A dependency declared anywhere else is not in the SBOM and is not matched at all, so it is reported as unread rather than as clean.
06 / Salesforce
Two half-findings that make one path.
A guest or community profile with read access to an object is a finding a reviewer might reasonably accept, because the site has to render something. Apex that runs without sharing is a finding a reviewer might reasonably accept, because plenty of legitimate code needs it. When both are true in the same org, an unauthenticated visitor reaches data the Apex never re-checks. Neither half describes that. The fusion is the finding.
| Check | Rule | Status | Severity | Evidence |
|---|---|---|---|---|
| Guest user accessvulkro-sf org guest-live | SF-GUEST-LIVE-001 | gap | HIGH | Guest profile for site partner-portal holds Read on Contact and Opportunity. |
| Permission set assignmentsvulkro-sf org perms | SF-PERM-ASSIGN-001 | gap | MED | Community profile holds Read on the custom object the portal controller returns. |
Neither row is unusual on its own. What follows is why they stop being ordinary once the Apex half is read beside them.
Half one: org posture
The guest profile on the community site holds Read on Contact. On its own this is a configuration choice a reviewer signs off every day.
profiles/partner-portal.profile-meta.xmlHalf two: Apex
The portal controller runs without sharing and returns Contact rows with no CRUD or FLS check. On its own this is a class declaration with a plausible reason behind it.
classes/PortalController.cls:41The path
An anonymous visitor on the community site reaches Contact records that the Apex never re-checks and the sharing model was never asked about. The exposure exists only because both halves are true, which is precisely why reading them in separate reports finds nothing.
Fusing Apex dataflow with org posture takes both halves in the same pass, which is what Vulkro for Salesforce already reads: profiles, permission sets and sharing rules alongside the Apex, Lightning and Flow source. The chain layer composes what that review found.
07 / Authorization
Nothing to authorize, because nothing is attacked.
Most of what makes a live engagement expensive is not the testing. It is the paperwork and the coordination that have to happen before anyone is allowed to send the first request, and the fact that all of it expires the next time you deploy. A static read has none of that surface, because there is no system on the other end of it.
A live engagement needs
- A scope document, agreed and countersigned
- A signed authorization letter naming the systems in scope
- A target allowlist and an agreed maintenance window
- Someone on call, because a probe can take production down
- A re-test after every fix, because the evidence expires with the deployment
Vulkro Red needs
- Read access to a checkout
That is the entire list. There is no window to book and nobody to put on call, because nothing is going to be sent at anything.
The red team runs inside one machine: the checkout it reads, the facts the engine builds, the chains it composes and the local model it consults over loopback. The single line that leaves belongs to the account layer, not to the review.
- Stays on this machine
- the checkout, engine facts, chain synthesis, attack report, and the local model on 127.0.0.1
- One crossing
- The account layer's entitlement check and a usage counter, on a debounced cadence. It never carries your code, your paths or your findings, and it never touches the application you are reviewing.
- Never crosses
- a request at your application, a probe at your infrastructure, source code, file paths, finding contents, the chains themselves
08 / The model
The model narrates. The engine decides what exists.
The reasoning pass runs against a model on your own machine, over loopback, at temperature zero. A model is genuinely good at reading a handler and saying what someone would try next, and it is entirely capable of saying something that is not true. So nothing it produces is taken on its word: every claim is adjudicated against the real call graph, the endpoint inventory and reachability before it can become a stage.
- Model claimEngine checkOutcome
- GET /invoices/:id is served without a sessionRoute resolved in the framework router table. The guard is mounted after this router at src/app.ts:88.kept
- POST /admin/impersonate accepts a forged role headerNo such route exists in the endpoint inventory.dropped, ungrounded
- The path parameter reaches findById unchangedCall graph resolves the path and taint agrees across three hops, with no sanitiser between them.kept
- The reporting endpoint exposes every tenantThe graph neither confirms nor contradicts it: the handler is reached through a string-keyed dispatch table.needs human trace
The adjudication is conservative in one direction only. A claim the graph contradicts with a cited reason is dropped. A claim the graph can neither confirm nor contradict is downgraded to needs human trace and stays in the report, because a call resolved at runtime can break a graph without making the path untrue, and a silent deletion is the one failure mode a reader can never notice.
A deterministic run skips the model entirely and composes chains from the engine findings alone: no model, no narration, and the same output every time. The model never changes a deterministic scan result, on this arm or any other.
09 / The team
The review finds it. The red team works out what it costs you.
Vulkro reviews your codebase and Vulkro for Salesforce reviews your Salesforce build, including the org configuration around it: the same review, pointed at a different subject. Those two are the defensive half. A chain is the offensive half, composed from what those reviews already found, using the same parser, the same call graph, the same taint engine and the same finding format. Nothing on this page is a second opinion from a second tool with its own idea of what a finding is.
Reviews your code
Goes through every line of your codebase before a release, the way a senior engineer would if they had the time, and tells you what a customer could exploit.
Reviews your Salesforce build
The same review for the part of your business that runs on Salesforce, including the settings in the org itself, and what the AppExchange security review will ask you for.
Attacks what they found
Takes the review and works out what an attacker would actually do with it: which small problems chain together into a real break-in, and which ones are noise.
One engine behind all three, so the red team works from what the review already foundIt all runs on your machine. Your code never leaves it.
10 / The limits
Stated here, before you find them.
A red-team report is only worth reading if the boundary around it is drawn as carefully as the claims inside it. So the constraints get the same treatment as the results: named, with the reason, in the same table shape.
| The claim | The constraint | Why |
|---|---|---|
| A penetration test | Vulkro Red is not a live penetration test and does not replace one. It reads a checkout and reasons about it. | Nothing is sent at a running system, so nothing about your deployment is observed. Where you need an attack executed against production, you need people and an authorization letter. |
| A proven exploit | No chain is ever proven. The ceiling is a strongly-grounded static path, and the report never prints the word confirmed. | Proof needs a live oracle: a request and a response that shows it worked. There is none, so the tier is not offered rather than approximated. |
| A reproducible reasoning pass | The model-driven pass is not bit-reproducible, so it is excluded from the scored benchmark and never mixed into the deterministic scanner results. | The pass runs at temperature zero and is stable in practice, but not guaranteed identical across model builds and hardware. The benchmark measures the deterministic engine only. |
| Exploitability in your deployment | A chain says a path exists in the code. It does not say an attacker can walk it as you have things deployed. | Network position, whatever sits in front of the route, and feature flags are not visible to a static read of the repository. |
| Cross-file dataflow on Java | Java taint is same-file only, so a Java chain is grounded inside a single file. Python, JavaScript, TypeScript and Go cross file boundaries up to four hops. | The Java arm does not carry a local-variable slot across method boundaries, and a hop it cannot follow is not asserted as one. |
| Calls resolved at runtime | Reflection, string-keyed dispatch and eval can break the call graph, so a real path can be missed or land below the reporting gate. | Static resolution has no value to follow. Where it happens the chain is down-ranked rather than deleted, so absence is visible. |
| Business logic | Whether a rule is the right rule is not checked. A pricing rule that is wrong but well written reads as clean. | There is no specification to compare the code against, so correctness of intent cannot be decided from the code alone. |
| A registry lookup | The supply-chain arm reads manifests, lockfiles, CI workflows and the local CVE bundle inside your repository. It never contacts a package registry. | The whole pass is offline. A package that turned hostile in a version you did not resolve is outside what a local read can see. |
If a constraint you care about is not on this list, assume it applies rather than assuming it was handled.