Skip to content

Beanshell-XML-Linter

Lint Release License: GPL v3

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.

What it checks

  • 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, leftover TODO/FIXME comments, bare printStackTrace() 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, bare AES; CWE-327), java.util.Random where SecureRandom belongs (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 to Class.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.

Usage

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.jar

Run 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

Pointing it at your rules

<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.

Suppressing a single finding

--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-secret

lint-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 /workspace

CI

See .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.

Running the test suite

mvn -B test

Adding a new rule

Rules 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);
}
  • block is the extracted <Source> block: block.content() is the raw BeanShell text, block.filePath() and block.startLine() are needed for mapping a match back to a real file/line.
  • masked is block.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 against masked.masked() instead of block.content() unless your rule specifically needs to see inside strings/comments (see HardcodedSecretRule, which needs the string value, and TodoFixmeRule, which needs comment text via masked.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 WARNING for style/anti-pattern rules; only the syntax/XML checks (bsh-syntax-error, bsh-lexical-error, bsh-internal-error, xml-not-well-formed) use ERROR, since those always fail the build regardless of --fail-on-warning and everything else shouldn't.
  • Match against masked.masked(), not block.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; EndToEndLintTest is 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 - see src/test/java/com/beanshellxmllinter/rules/*RuleTest.java for the pattern: build a SourceBlock from a small hand-written snippet, run it through CommentAndStringMasker.mask(...), and call rule.check(block, masked) directly, with at least one case that should fire and one that shouldn't.

About

CLI that lints BeanShell embedded in SailPoint IdentityIQ XML (Rules, Workflow steps, Forms) using the real BeanShell parser, plus security/style anti-pattern checks. Runs locally and as a CI gate.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages