Sailpoint IIQ devs need a good way to lint BeanShell embedded in XML config files (Rules, Workflow steps, Forms, and anything else that carries a <Source> block). There wasn't one, so this is one approach: a CLI that extracts embedded BeanShell from IIQ XML and checks it with the real BeanShell parser, meant to run both locally and as a CI gate.
- Syntax - every
<Source>block is parsed with the actual BeanShell parser (not a reimplemented grammar), so acceptance matches what BeanShell itself would accept. - Style/anti-pattern rules - empty catch blocks, overly broad
catch (Exception|Throwable),System.exit(...)calls, apparent hardcoded credentials, leftoverTODO/FIXMEcomments, bareprintStackTrace()calls, and (per the Google Java Style Guide) wildcard imports. - Common Java/BeanShell security anti-patterns, each mapped to the CWE category it's an instance of - weak crypto algorithms (
MD5,DES, bareAES; CWE-327),java.util.RandomwhereSecureRandombelongs (CWE-330), unguarded Java deserialization (CWE-502), disabled TLS certificate/hostname verification (CWE-295), OS command execution (CWE-78), XML parsing without disabling external entities/XXE (CWE-611), logging calls that appear to include a sensitive value (CWE-532), SQL/LDAP queries built via string concatenation (CWE-89/CWE-90), file paths built via string concatenation (CWE-22), URLs constructed from a variable rather than a fixed literal (CWE-918), and non-constant arguments toClass.forName(...)(CWE-470).
All rules - style and security alike - are regex-over-masked-text pattern matches, not dataflow/taint analysis: they flag "this risky construct is present," not "this is provably exploitable from untrusted input." That's a deliberate scope choice (see Adding a new rule below) - the same category of tool as Semgrep or gosec, not a SonarQube-style symbol-resolving analyzer. Relatedly: this catches the code patterns behind common CVE classes (SQL injection, XXE, insecure deserialization, etc.), not CVEs themselves - a CVE is a specific vulnerable version of a specific dependency, which requires knowing what's on the classpath at runtime, something a source-only linter has no visibility into. That's what a Software Composition Analysis tool (Dependabot, Snyk, OWASP Dependency-Check) is for; this project doesn't try to replace one. Treat findings as things to audit, not proven vulnerabilities; --disable <ruleId> is there for whichever ones are too noisy for a given codebase. (An unused-import rule was tried and removed for exactly this reason: BeanShell rules routinely reference IIQ API classes without a literal token match the rule could find - context-bound variables typed only implicitly, reflection, etc. - so a purely textual check produced too many false positives to be useful on real-world IIQ exports.)
Known limitation: the vendored BeanShell parser (2.1.1, the newest version with an official pre-built release - see the bsh.version property comment in pom.xml for the full story) cannot parse the diamond operator (new ArrayList<>()), nested generics (Map<String, List<String>>), or try-with-resources. Real IIQ rules using those constructs will currently produce a bsh-syntax-error false positive. This is a real gap in the wider BeanShell ecosystem, not something this project papered over - see GrammarCoverageTest for exactly what's covered.
IIQ Rule objects support a language attribute (<Rule language="beanshell">) that selects which script engine actually runs the <Source> content - BeanShell is only the default when the attribute is absent. This linter only checks the BeanShell case: it reads the language attribute off the file's root element, and if it's set to anything other than beanshell (case-insensitive), every <Source> block in that file is skipped entirely - no syntax check, no style/security rules - rather than run BeanShell-specific checks against, say, PowerShell source and produce bogus findings.
Get a jar - either build it, or grab one already built from Releases:
mvn -B package # produces target/beanshell-xml-linter.jar
# or:
curl -sSLf -o beanshell-xml-linter.jar \
https://github.com/DLaMott/Beanshell-XML-Linter/releases/download/v1.0.0/beanshell-xml-linter.jarRun it:
java -jar beanshell-xml-linter.jar path/to/rules/ # file or directory, searched recursively for *.xml
java -jar beanshell-xml-linter.jar path/to/rules/ --fail-on-warning
java -jar beanshell-xml-linter.jar path/to/rules/ --disable empty-catch-block --disable todo-fixme-comment
java -jar beanshell-xml-linter.jar path/to/rules/ --changed-since origin/main # only *.xml files that differ from origin/main<path> accepts more than one argument, and each one is searched recursively to any depth - java -jar beanshell-xml-linter.jar Config/ _certs/ _config/ scans all three trees (and however deeply nested each one's rules are) in a single run, deduplicated if they overlap.
Given that, it's tempting to just point it at the repo root and let recursion find everything. Don't - list your actual rule directories instead. The linter treats every *.xml file it finds as fair game, not just SailPoint rule exports, and a file that isn't well-formed XML at all is always an ERROR regardless of whether it has anything to do with BeanShell rules. Point it at a whole repo and an unrelated malformed XML file anywhere in it - a stray build config, a fixture in some other tool's test suite, anything - will fail your lint gate with a confusing error about a file you never meant to lint. Scoping to the directories that actually hold rule exports avoids that entirely; the only reason to widen it is if the repo is dedicated solely to IIQ export content, in which case the root is safe by construction.
--changed-since <ref> shells out to git diff --name-only scoped to the given path(s) the same way, so it needs git on PATH and needs to be run from inside (or with paths scoped to) a git working tree - the normal case for a CI job. Use it to lint only what changed in a merge/pull request instead of re-scanning every rule directory every run. Under Docker specifically, a bind-mounted repo is usually owned by a different UID than the container's, and git refuses to touch it ("detected dubious ownership") until you tell it the mount is trusted: run git config --global --add safe.directory /workspace (or wherever it's mounted) inside the container before invoking the jar.
Output is plain text to stdout, one line per finding:
examples/example6.xml:74: [WARNING] [empty-catch-block] Empty catch block silently swallows exceptions
examples/known-bad-not-well-formed.xml:1: [ERROR] [xml-not-well-formed] Content is not allowed in prolog.
2 error(s), 4 warning(s) across 7 file(s) scanned
Exit code is non-zero if any ERROR-severity finding exists (syntax errors, XML parse failures), or if any WARNING exists and --fail-on-warning was passed. A --changed-since ref that git can't resolve (or a missing git binary) also exits non-zero (2), same as any other usage error.
--disable <ruleId> turns a rule off for the whole run. For a one-off case where a rule's flagging something intentional - a documented false positive, a test fixture that legitimately needs a fake-looking secret - add a suppression comment in the BeanShell source instead, the same way // eslint-disable-line works:
// lint-disable-next-line hardcoded-secret
String password = "intentionally-fake-for-this-test";
String password2 = "also-fake"; // lint-disable-line hardcoded-secretlint-disable-next-line <ruleId> suppresses findings on the line after the comment; lint-disable-line <ruleId> suppresses findings on the comment's own line. Both accept a comma-separated list of rule ids. Only single-line comments count as directives - a directive inside a comment that spans multiple lines is ignored, since "next line" wouldn't be well-defined.
Or via Docker (built locally - no image is published to a registry; see the CI section below for why):
docker build -t beanshell-xml-linter .
docker run --rm -v "$(pwd)/examples:/workspace" beanshell-xml-linter /workspaceSee .github/workflows/lint.yml and .gitlab-ci.yml for ready-to-adapt pipeline configs. Both show two options: build from source with Maven, or download the jar from GitHub Releases (published by .github/workflows/release.yml on every vX.Y.Z tag) and run it with a plain JRE - the latter is the one to reach for if your CI can't pull from a container registry, since it's a single HTTPS file download.
mvn -B testRules live in src/main/java/com/beanshellxmllinter/rules/ and implement the LintRule interface:
public interface LintRule {
String id();
Severity defaultSeverity();
List<Finding> check(SourceBlock block, MaskResult masked);
}blockis the extracted<Source>block:block.content()is the raw BeanShell text,block.filePath()andblock.startLine()are needed for mapping a match back to a real file/line.maskedisblock.content()with comment and string-literal contents blanked out (same length, same newlines, so line numbers still line up) - computed once per block and shared across all rules, so match againstmasked.masked()instead ofblock.content()unless your rule specifically needs to see inside strings/comments (seeHardcodedSecretRule, which needs the string value, andTodoFixmeRule, which needs comment text viamasked.commentSpans()).
The existing rules are all regex-over-masked-text, which is enough for anything expressible as a token/phrase pattern. PrintStackTraceRule - flagging bare printStackTrace() calls - is about as minimal as a rule gets, and it's a real shipped rule (print-stack-trace), not a hypothetical:
package com.beanshellxmllinter.rules;
import com.beanshellxmllinter.extract.SourceBlock;
import com.beanshellxmllinter.model.Finding;
import com.beanshellxmllinter.model.Severity;
import com.beanshellxmllinter.syntax.LineMapper;
import com.beanshellxmllinter.tokenize.CommentAndStringMasker.MaskResult;
import com.beanshellxmllinter.tokenize.LineIndex;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class PrintStackTraceRule implements LintRule {
private static final Pattern PATTERN = Pattern.compile("\\.printStackTrace\\s*\\(");
@Override
public String id() {
return "print-stack-trace";
}
@Override
public Severity defaultSeverity() {
return Severity.WARNING;
}
@Override
public List<Finding> check(SourceBlock block, MaskResult masked) {
List<Finding> findings = new ArrayList<>();
Matcher matcher = PATTERN.matcher(masked.masked());
while (matcher.find()) {
int localLine = LineIndex.lineAt(masked.masked(), matcher.start());
int fileLine = LineMapper.mapLine(block.startLine(), localLine);
findings.add(new Finding(block.filePath(), fileLine, defaultSeverity(), id(),
"printStackTrace() writes to stderr instead of the IIQ logger"));
}
return findings;
}
}A new rule of your own needs the same last step: register it in RuleRegistry.BUILTIN_RULES (src/main/java/com/beanshellxmllinter/rules/RuleRegistry.java) so it's picked up by default and disableable via --disable <your-rule-id>.
A few conventions to follow, based on the existing rules:
- Rule ids are kebab-case and stable - they're a public interface (
--disable <id>), so don't rename one without good reason. - Default severity is
WARNINGfor style/anti-pattern rules; only the syntax/XML checks (bsh-syntax-error,bsh-lexical-error,bsh-internal-error,xml-not-well-formed) useERROR, since those always fail the build regardless of--fail-on-warningand everything else shouldn't. - Match against
masked.masked(), notblock.content(), unless you have a specific reason not to (see above) - otherwise your pattern will false-positive on the same text appearing inside a string literal or comment. - Test against real fixtures where possible.
src/test/resources/examples/has real (and one synthetic) SailPoint XML files;EndToEndLintTestis the place to add an assertion that your rule fires on real code, not just a hand-crafted snippet. For constructs that don't happen to appear in those fixtures (most of the security rules, for instance), a direct unit test against the rule is fine - seesrc/test/java/com/beanshellxmllinter/rules/*RuleTest.javafor the pattern: build aSourceBlockfrom a small hand-written snippet, run it throughCommentAndStringMasker.mask(...), and callrule.check(block, masked)directly, with at least one case that should fire and one that shouldn't.