make ApiLabel into a IPA type, and cache the public API of ApiGraphs

This commit is contained in:
Erik Krogh Kristensen
2021-11-19 11:08:25 +01:00
parent 1f3f7e9ccc
commit 089d030bc2
13 changed files with 352 additions and 167 deletions

View File

@@ -11,6 +11,7 @@
import javascript
private import semmle.javascript.dataflow.internal.FlowSteps as FlowSteps
private import internal.CachedStages
/**
* Provides classes and predicates for working with APIs defined or used in a database.
@@ -33,6 +34,7 @@ module API {
* As another example, in the assignment `exports.plusOne = (x) => x+1` the two references to
* `x` are uses of the first parameter of `plusOne`.
*/
pragma[inline]
DataFlow::Node getAUse() {
exists(DataFlow::SourceNode src | Impl::use(this, src) |
Impl::trackUseNode(src).flowsTo(result)
@@ -105,22 +107,31 @@ module API {
* For example, modules have an `exports` member representing their exports, and objects have
* their properties as members.
*/
bindingset[m]
bindingset[result]
Node getMember(string m) { result = getASuccessor(Label::member(m)) }
cached
Node getMember(string m) {
Stages::APIStage::ref() and
result = getASuccessor(Label::member(m))
}
/**
* Gets a node representing a member of this API component where the name of the member is
* not known statically.
*/
Node getUnknownMember() { result = getASuccessor(Label::unknownMember()) }
cached
Node getUnknownMember() {
Stages::APIStage::ref() and
result = getASuccessor(Label::unknownMember())
}
/**
* Gets a node representing a member of this API component where the name of the member may
* or may not be known statically.
*/
cached
Node getAMember() {
result = getASuccessor(Label::member(_)) or
Stages::APIStage::ref() and
result = getMember(_)
or
result = getUnknownMember()
}
@@ -135,7 +146,11 @@ module API {
* This predicate may have multiple results when there are multiple constructor calls invoking this API component.
* Consider using `getAnInstantiation()` if there is a need to distinguish between individual constructor calls.
*/
Node getInstance() { result = getASuccessor(Label::instance()) }
cached
Node getInstance() {
Stages::APIStage::ref() and
result = getASuccessor(Label::instance())
}
/**
* Gets a node representing the `i`th parameter of the function represented by this node.
@@ -143,16 +158,16 @@ module API {
* This predicate may have multiple results when there are multiple invocations of this API component.
* Consider using `getAnInvocation()` if there is a need to distingiush between individual calls.
*/
bindingset[i]
Node getParameter(int i) { result = getASuccessor(Label::parameter(i)) }
cached
Node getParameter(int i) {
Stages::APIStage::ref() and
result = getASuccessor(Label::parameter(i))
}
/**
* Gets the number of parameters of the function represented by this node.
*/
int getNumParameter() {
result =
max(string s | exists(getASuccessor(Label::parameterByStringIndex(s))) | s.toInt()) + 1
}
int getNumParameter() { result = max(int s | exists(getParameter(s))) + 1 }
/**
* Gets a node representing the last parameter of the function represented by this node.
@@ -165,7 +180,11 @@ module API {
/**
* Gets a node representing the receiver of the function represented by this node.
*/
Node getReceiver() { result = getASuccessor(Label::receiver()) }
cached
Node getReceiver() {
Stages::APIStage::ref() and
result = getASuccessor(Label::receiver())
}
/**
* Gets a node representing a parameter or the receiver of the function represented by this
@@ -175,8 +194,11 @@ module API {
* there are multiple invocations of this API component.
* Consider using `getAnInvocation()` if there is a need to distingiush between individual calls.
*/
cached
Node getAParameter() {
result = getASuccessor(Label::parameterByStringIndex(_)) or
Stages::APIStage::ref() and
result = getParameter(_)
or
result = getReceiver()
}
@@ -186,18 +208,30 @@ module API {
* This predicate may have multiple results when there are multiple invocations of this API component.
* Consider using `getACall()` if there is a need to distingiush between individual calls.
*/
Node getReturn() { result = getASuccessor(Label::return()) }
cached
Node getReturn() {
Stages::APIStage::ref() and
result = getASuccessor(Label::return())
}
/**
* Gets a node representing the promised value wrapped in the `Promise` object represented by
* this node.
*/
Node getPromised() { result = getASuccessor(Label::promised()) }
cached
Node getPromised() {
Stages::APIStage::ref() and
result = getASuccessor(Label::promised())
}
/**
* Gets a node representing the error wrapped in the `Promise` object represented by this node.
*/
Node getPromisedError() { result = getASuccessor(Label::promisedError()) }
cached
Node getPromisedError() {
Stages::APIStage::ref() and
result = getASuccessor(Label::promisedError())
}
/**
* Gets a string representation of the lexicographically least among all shortest access paths
@@ -209,13 +243,13 @@ module API {
* Gets a node such that there is an edge in the API graph between this node and the other
* one, and that edge is labeled with `lbl`.
*/
Node getASuccessor(string lbl) { Impl::edge(this, lbl, result) }
Node getASuccessor(ApiLabel lbl) { Impl::edge(this, lbl, result) }
/**
* Gets a node such that there is an edge in the API graph between that other node and
* this one, and that edge is labeled with `lbl`
*/
Node getAPredecessor(string lbl) { this = result.getASuccessor(lbl) }
Node getAPredecessor(ApiLabel lbl) { this = result.getASuccessor(lbl) }
/**
* Gets a node such that there is an edge in the API graph between this node and the other
@@ -281,9 +315,9 @@ module API {
length = 0 and
result = ""
or
exists(Node pred, string lbl, string predpath |
exists(Node pred, ApiLabel lbl, string predpath |
Impl::edge(pred, lbl, this) and
lbl != "" and
not lbl instanceof Label::LabelAlias and
predpath = pred.getAPath(length - 1) and
exists(string space | if length = 1 then space = "" else space = " " |
result = "(" + lbl + space + predpath + ")" and
@@ -348,6 +382,11 @@ module API {
/** Gets a data-flow node that defines this entry point. */
abstract DataFlow::Node getARhs();
/** Gets an API-node for this entry point. */
API::Node getNode() {
result = root().getASuccessor(any(Label::LabelEntryPoint l | l.getEntryPoint() = this))
}
}
/**
@@ -431,27 +470,16 @@ module API {
hasSemantics(imp)
}
/** Gets the definition of module `m`. */
private Module importableModule(string m) {
exists(NPMPackage pkg, PackageJSON json |
json = pkg.getPackageJSON() and not json.isPrivate()
|
result = pkg.getMainModule() and
not result.isExterns() and
m = pkg.getPackageName()
)
}
/**
* Holds if `rhs` is the right-hand side of a definition of a node that should have an
* incoming edge from `base` labeled `lbl` in the API graph.
*/
cached
predicate rhs(TApiNode base, string lbl, DataFlow::Node rhs) {
predicate rhs(TApiNode base, ApiLabel lbl, DataFlow::Node rhs) {
hasSemantics(rhs) and
(
base = MkRoot() and
rhs = lbl.(EntryPoint).getARhs()
rhs = lbl.(Label::LabelEntryPoint).getEntryPoint().getARhs()
or
exists(string m, string prop |
base = MkModuleExport(m) and
@@ -563,7 +591,7 @@ module API {
*/
pragma[noinline]
private predicate propertyRead(
DataFlow::SourceNode pred, string propDesc, string lbl, DataFlow::Node ref
DataFlow::SourceNode pred, string propDesc, ApiLabel lbl, DataFlow::Node ref
) {
ref = pred.getAPropertyRead() and
lbl = Label::memberFromRef(ref) and
@@ -587,11 +615,11 @@ module API {
* `lbl` in the API graph.
*/
cached
predicate use(TApiNode base, string lbl, DataFlow::Node ref) {
predicate use(TApiNode base, ApiLabel lbl, DataFlow::Node ref) {
hasSemantics(ref) and
(
base = MkRoot() and
ref = lbl.(EntryPoint).getAUse()
ref = lbl.(Label::LabelEntryPoint).getEntryPoint().getAUse()
or
// property reads
exists(DataFlow::SourceNode src, DataFlow::SourceNode pred, string propDesc |
@@ -678,33 +706,6 @@ module API {
nd = MkUse(ref)
}
/** Holds if module `m` exports `rhs`. */
private predicate exports(string m, DataFlow::Node rhs) {
exists(Module mod | mod = importableModule(m) |
rhs = mod.(AmdModule).getDefine().getModuleExpr().flow()
or
exports(m, "default", rhs)
or
exists(ExportAssignDeclaration assgn | assgn.getTopLevel() = mod |
rhs = assgn.getExpression().flow()
)
or
rhs = mod.(Closure::ClosureModule).getExportsVariable().getAnAssignedExpr().flow()
)
}
/** Holds if module `m` exports `rhs` under the name `prop`. */
private predicate exports(string m, string prop, DataFlow::Node rhs) {
exists(ExportDeclaration exp | exp.getEnclosingModule() = importableModule(m) |
rhs = exp.getSourceNode(prop)
or
exists(Variable v |
exp.exportsAs(v, prop) and
rhs = v.getAnAssignedExpr().flow()
)
)
}
private import semmle.javascript.dataflow.TypeTracking
/**
@@ -863,7 +864,8 @@ module API {
* Holds if there is an edge from `pred` to `succ` in the API graph that is labeled with `lbl`.
*/
cached
predicate edge(TApiNode pred, string lbl, TApiNode succ) {
predicate edge(TApiNode pred, ApiLabel lbl, TApiNode succ) {
Stages::APIStage::ref() and
exists(string m |
pred = MkRoot() and
lbl = Label::mod(m)
@@ -970,6 +972,7 @@ module API {
/**
* Gets an API node where a RHS of the node is the `i`th argument to this call.
*/
pragma[noinline]
private Node getAParameterCandidate(int i) { result.getARhs() = getArgument(i) }
/** Gets the API node for a parameter of this invocation. */
@@ -996,89 +999,224 @@ module API {
/** A `new` call connected to the API graph. */
class NewNode extends InvokeNode, DataFlow::NewNode { }
/** A label in the API-graph */
abstract class ApiLabel extends Label::TLabel {
string toString() { result = "???" }
}
private module Label {
newtype TLabel =
MkLabelMod(string mod) {
exists(Impl::MkModuleExport(mod)) or
exists(Impl::MkModuleImport(mod))
} or
MkLabelInstance() or
MkLabelMember(string prop) {
exports(_, prop, _) or
exists(any(DataFlow::ClassNode c).getInstanceMethod(prop)) or
prop = "exports" or
prop = any(CanonicalName c).getName() or
prop = any(DataFlow::PropRef p).getPropertyName() or
exists(Impl::MkTypeUse(_, prop)) or
exists(any(Module m).getAnExportedValue(prop))
} or
MkLabelUnknownMember() or
MkLabelParameter(int i) {
i =
[-1 .. max(int args |
args = any(InvokeExpr invk).getNumArgument() or
args = any(Function f).getNumParameter()
)] or
i = [0 .. 10]
} or
MkLabelReturn() or
MkLabelPromised() or
MkLabelPromisedError() or
MkLabelAlias() or
MkLabelEntryPoint(API::EntryPoint e)
class LabelEntryPoint extends ApiLabel {
API::EntryPoint e;
LabelEntryPoint() { this = MkLabelEntryPoint(e) }
API::EntryPoint getEntryPoint() { result = e }
override string toString() { result = e }
}
class LabelAlias extends ApiLabel {
LabelAlias() { this = MkLabelAlias() }
override string toString() { result = "" }
}
class LabelPromised extends ApiLabel {
LabelPromised() { this = MkLabelPromised() }
override string toString() { result = "promised" }
}
class LabelPromisedError extends ApiLabel {
LabelPromisedError() { this = MkLabelPromisedError() }
override string toString() { result = "promised" }
}
class LabelReturn extends ApiLabel {
LabelReturn() { this = MkLabelReturn() }
override string toString() { result = "return" }
}
class LabelMod extends ApiLabel {
string mod;
LabelMod() { this = MkLabelMod(mod) }
string getMod() { result = mod }
override string toString() { result = "module " + mod }
}
class LabelInstance extends ApiLabel {
LabelInstance() { this = MkLabelInstance() }
override string toString() { result = "instance" }
}
class LabelMember extends ApiLabel {
string prop;
LabelMember() { this = MkLabelMember(prop) }
string getProperty() { result = prop }
override string toString() { result = "member " + prop }
}
class LabelUnknownMember extends ApiLabel {
LabelUnknownMember() { this = MkLabelUnknownMember() }
override string toString() { result = "member *" }
}
class LabelParameter extends ApiLabel {
int i;
LabelParameter() { this = MkLabelParameter(i) }
override string toString() { result = "parameter " + i }
int getIndex() { result = i }
}
/** Gets the edge label for the module `m`. */
LabelMod mod(string m) { result.getMod() = m }
/** Gets the `member` edge label for member `m`. */
bindingset[m]
bindingset[result]
LabelMember member(string m) { result.getProperty() = m }
/** Gets the `member` edge label for the unknown member. */
LabelUnknownMember unknownMember() { any() }
/**
* Gets a property name referred to by the given dynamic property access,
* allowing one property flow step in the process (to allow flow through imports).
*
* This is to support code patterns where the property name is actually constant,
* but the property name has been factored into a library.
*/
private string getAnIndirectPropName(DataFlow::PropRef ref) {
exists(DataFlow::Node pred |
FlowSteps::propertyFlowStep(pred, ref.getPropertyNameExpr().flow()) and
result = pred.getStringValue()
)
}
/**
* Gets unique result of `getAnIndirectPropName` if there is one.
*/
private string getIndirectPropName(DataFlow::PropRef ref) {
result = unique(string s | s = getAnIndirectPropName(ref))
}
/** Gets the `member` edge label for the given property reference. */
ApiLabel memberFromRef(DataFlow::PropRef pr) {
exists(string pn | pn = pr.getPropertyName() or pn = getIndirectPropName(pr) |
result = member(pn) and
// only consider properties with alphanumeric(-ish) names, excluding special properties
// and properties whose names look like they are meant to be internal
pn.regexpMatch("(?!prototype$|__)[\\w_$][\\w\\-.$]*")
)
or
not exists(pr.getPropertyName()) and
not exists(getIndirectPropName(pr)) and
result = unknownMember()
}
/** Gets the `instance` edge label. */
LabelInstance instance() { any() }
/**
* Gets the `parameter` edge label for the `i`th parameter.
*
* The receiver is considered to be parameter -1.
*/
LabelParameter parameter(int i) { result.getIndex() = i }
/** Gets the `parameter` edge label for the receiver. */
LabelParameter receiver() { result = parameter(-1) }
/** Gets the `return` edge label. */
LabelReturn return() { any() }
/** Gets the `alias` (empty) edge label. */
LabelAlias alias() { any() }
/** Gets the `promised` edge label connecting a promise to its contained value. */
MkLabelPromised promised() { any() }
/** Gets the `promisedError` edge label connecting a promise to its rejected value. */
MkLabelPromisedError promisedError() { any() }
}
}
private module Label {
/** Gets the edge label for the module `m`. */
bindingset[m]
bindingset[result]
string mod(string m) { result = "module " + m }
/** Gets the `member` edge label for member `m`. */
bindingset[m]
bindingset[result]
string member(string m) { result = "member " + m }
/** Gets the `member` edge label for the unknown member. */
string unknownMember() { result = "member *" }
/**
* Gets a property name referred to by the given dynamic property access,
* allowing one property flow step in the process (to allow flow through imports).
*
* This is to support code patterns where the property name is actually constant,
* but the property name has been factored into a library.
*/
private string getAnIndirectPropName(DataFlow::PropRef ref) {
exists(DataFlow::Node pred |
FlowSteps::propertyFlowStep(pred, ref.getPropertyNameExpr().flow()) and
result = pred.getStringValue()
)
}
/**
* Gets unique result of `getAnIndirectPropName` if there is one.
*/
private string getIndirectPropName(DataFlow::PropRef ref) {
result = unique(string s | s = getAnIndirectPropName(ref))
}
/** Gets the `member` edge label for the given property reference. */
string memberFromRef(DataFlow::PropRef pr) {
exists(string pn | pn = pr.getPropertyName() or pn = getIndirectPropName(pr) |
result = member(pn) and
// only consider properties with alphanumeric(-ish) names, excluding special properties
// and properties whose names look like they are meant to be internal
pn.regexpMatch("(?!prototype$|__)[\\w_$][\\w\\-.$]*")
/** Holds if module `m` exports `rhs`. */
private predicate exports(string m, DataFlow::Node rhs) {
exists(Module mod | mod = importableModule(m) |
rhs = mod.(AmdModule).getDefine().getModuleExpr().flow()
or
exports(m, "default", rhs)
or
exists(ExportAssignDeclaration assgn | assgn.getTopLevel() = mod |
rhs = assgn.getExpression().flow()
)
or
not exists(pr.getPropertyName()) and
not exists(getIndirectPropName(pr)) and
result = unknownMember()
}
/** Gets the `instance` edge label. */
string instance() { result = "instance" }
/**
* Gets the `parameter` edge label for the parameter `s`.
*
* This is an internal helper predicate; use `parameter` instead.
*/
bindingset[result]
bindingset[s]
string parameterByStringIndex(string s) {
result = "parameter " + s and
s.toInt() >= -1
}
/**
* Gets the `parameter` edge label for the `i`th parameter.
*
* The receiver is considered to be parameter -1.
*/
bindingset[i]
string parameter(int i) { result = parameterByStringIndex(i.toString()) }
/** Gets the `parameter` edge label for the receiver. */
string receiver() { result = "parameter -1" }
/** Gets the `return` edge label. */
string return() { result = "return" }
/** Gets the `promised` edge label connecting a promise to its contained value. */
string promised() { result = "promised" }
/** Gets the `promisedError` edge label connecting a promise to its rejected value. */
string promisedError() { result = "promisedError" }
rhs = mod.(Closure::ClosureModule).getExportsVariable().getAnAssignedExpr().flow()
)
}
/** Holds if module `m` exports `rhs` under the name `prop`. */
private predicate exports(string m, string prop, DataFlow::Node rhs) {
exists(ExportDeclaration exp | exp.getEnclosingModule() = importableModule(m) |
rhs = exp.getSourceNode(prop)
or
exists(Variable v |
exp.exportsAs(v, prop) and
rhs = v.getAnAssignedExpr().flow()
)
)
}
/** Gets the definition of module `m`. */
private Module importableModule(string m) {
exists(NPMPackage pkg, PackageJSON json | json = pkg.getPackageJSON() and not json.isPrivate() |
result = pkg.getMainModule() and
not result.isExterns() and
m = pkg.getPackageName()
)
}

View File

@@ -23,7 +23,7 @@ module D3 {
or
result = API::moduleImport("d3-node").getInstance().getMember("d3")
or
result = API::root().getASuccessor(any(D3GlobalEntry i))
result = any(D3GlobalEntry i).getNode()
}
/**

View File

@@ -17,7 +17,7 @@ module History {
* Gets a reference to the [`history`](https://npmjs.org/package/history) library.
*/
private API::Node history() {
result = [API::moduleImport("history"), API::root().getASuccessor(any(HistoryGlobalEntry h))]
result = [API::moduleImport("history"), any(HistoryGlobalEntry h).getNode()]
}
/**

View File

@@ -27,7 +27,7 @@ private module Immutable {
API::Node immutableImport() {
result = API::moduleImport("immutable")
or
result = API::root().getASuccessor(any(ImmutableGlobalEntry i))
result = any(ImmutableGlobalEntry i).getNode()
}
/**

View File

@@ -45,7 +45,7 @@ private module Console {
*/
private API::Node console() {
result = API::moduleImport("console") or
result = API::root().getASuccessor(any(ConsoleGlobalEntry e))
result = any(ConsoleGlobalEntry e).getNode()
}
/**

View File

@@ -151,7 +151,7 @@ module NestJS {
private API::Node validationPipe() {
result = nestjs().getMember("ValidationPipe")
or
result = API::root().getASuccessor(any(ValidationNodeEntry e))
result = any(ValidationNodeEntry e).getNode()
}
/**

View File

@@ -1111,9 +1111,7 @@ module Redux {
/** A heuristic call to `connect`, recognized by it taking arguments named `mapStateToProps` and `mapDispatchToProps`. */
private class HeuristicConnectFunction extends ConnectCall {
HeuristicConnectFunction() {
this = API::root().getASuccessor(any(HeuristicConnectEntryPoint e)).getACall()
}
HeuristicConnectFunction() { this = any(HeuristicConnectEntryPoint e).getNode().getACall() }
override API::Node getMapStateToProps() {
result = getAParameter() and

View File

@@ -35,7 +35,7 @@ module Vue {
API::Node vueLibrary() {
result = API::moduleImport("vue")
or
result = API::root().getASuccessor(any(GlobalVueEntryPoint e))
result = any(GlobalVueEntryPoint e).getNode()
}
/**
@@ -51,7 +51,7 @@ module Vue {
or
result = vueLibrary().getMember("component").getReturn()
or
result = API::root().getASuccessor(any(VueFileImportEntryPoint e))
result = any(VueFileImportEntryPoint e).getNode()
}
/**

View File

@@ -233,6 +233,43 @@ module Stages {
}
}
/**
* The `APIStage` stage.
*/
cached
module APIStage {
/**
* Always holds.
* Ensures that a predicate is evaluated as part of the APIStage stage.
*/
cached
predicate ref() { 1 = 1 }
/**
* DONT USE!
* Contains references to each predicate that use the above `ref` predicate.
*/
cached
predicate backref() {
1 = 1
or
exists(
API::moduleImport("foo")
.getMember("bar")
.getUnknownMember()
.getAMember()
.getAParameter()
.getPromised()
.getReturn()
.getParameter(2)
.getUnknownMember()
.getInstance()
.getReceiver()
.getPromisedError()
)
}
}
/**
* The `taint` stage.
*/

View File

@@ -211,11 +211,9 @@ module ExternalAPIUsedWithUntrustedData {
node = getNamedParameter(base.getAParameter(), paramName) and
result = basename + ".[callback].[param '" + paramName + "']"
or
exists(string callbackName, string index |
node =
getNamedParameter(base.getASuccessor("parameter " + index).getMember(callbackName),
paramName) and
index != "-1" and // ignore receiver
exists(string callbackName, int index |
node = getNamedParameter(base.getParameter(index).getMember(callbackName), paramName) and
index != -1 and // ignore receiver
result =
basename + ".[callback " + index + " '" + callbackName + "'].[param '" + paramName +
"']"

View File

@@ -117,16 +117,23 @@ private class RemoteFlowSourceAccessPath extends JSONString {
string getSourceType() { result = sourceType }
/** Gets the `i`th component of the access path specifying this remote flow source. */
string getComponent(int i) {
API::ApiLabel getComponent(int i) {
exists(string raw | raw = this.getValue().splitAt(".", i + 1) |
i = 0 and
result = "ExternalRemoteFlowSourceSpec " + raw
result
.(API::EdgeLabel::LabelEntryPoint)
.getEntryPoint()
.(ExternalRemoteFlowSourceSpecEntryPoint)
.getName() = raw
or
i > 0 and
result = API::EdgeLabel::member(raw)
)
}
/** Gets the first part of this access path. E.g. for "window.user.name" the result is "window". */
string getRootPath() { result = this.getValue().splitAt(".", 1) }
/** Gets the index of the last component of this access path. */
int getMaxComponentIndex() { result = max(int i | exists(getComponent(i))) }
@@ -154,10 +161,12 @@ private class ExternalRemoteFlowSourceSpecEntryPoint extends API::EntryPoint {
string name;
ExternalRemoteFlowSourceSpecEntryPoint() {
this = any(RemoteFlowSourceAccessPath s).getComponent(0) and
name = any(RemoteFlowSourceAccessPath s).getRootPath() and
this = "ExternalRemoteFlowSourceSpec " + name
}
string getName() { result = name }
override DataFlow::SourceNode getAUse() { result = DataFlow::globalVarRef(name) }
override DataFlow::Node getARhs() { none() }

View File

@@ -12,4 +12,4 @@ import javascript
import meta.MetaMetrics
select projectRoot(),
count(API::Node pred, string lbl, API::Node succ | succ = pred.getASuccessor(lbl))
count(API::Node pred, API::ApiLabel lbl, API::Node succ | succ = pred.getASuccessor(lbl))

View File

@@ -62,7 +62,8 @@ class Assertion extends Comment {
i = getPathLength() and
result = API::root()
or
result = lookup(i + 1).getASuccessor(getEdgeLabel(i))
result =
lookup(i + 1).getASuccessor(any(API::ApiLabel label | label.toString() = getEdgeLabel(i)))
}
predicate isNegative() { polarity = "!" }
@@ -79,7 +80,11 @@ class Assertion extends Comment {
then
suffix =
"it does have outgoing edges labelled " +
concat(string lbl | exists(nd.getASuccessor(lbl)) | lbl, ", ") + "."
concat(string lbl |
exists(nd.getASuccessor(any(API::ApiLabel label | label.toString() = lbl)))
|
lbl, ", "
) + "."
else suffix = "it has no outgoing edges at all."
|
result = prefix + " " + suffix