package cli
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/colibrisec/ojo/internal/config"
"github.com/colibrisec/ojo/internal/customrules"
"github.com/colibrisec/ojo/internal/ignore"
"github.com/colibrisec/ojo/internal/manifest"
"github.com/colibrisec/ojo/internal/misconfig"
"github.com/colibrisec/ojo/internal/quality"
"github.com/colibrisec/ojo/internal/report"
"github.com/colibrisec/ojo/internal/sast"
"github.com/colibrisec/ojo/internal/secret"
"github.com/colibrisec/ojo/internal/vex"
"github.com/colibrisec/ojo/internal/walk"
)
func fsCmd() *cobra.Command {
var format string
var scanners string
var configPath string
var gitlab bool
var rulesDir string
var ignoreFile string
var cyclonedxVersion string
var secretRulesFile string
var secretGitHistory bool
var kevFlag bool
var vexFile string
var respectGitignore bool
var sarifOmitSuppressed bool
cmd := &cobra.Command{
Use: "fs [path]",
Short: "Scan a filesystem path for vulnerabilities, secrets, and misconfiguration",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
root := "."
if len(args) == 1 {
root = args[0]
}
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if cfg.Format != "" && !cmd.Flags().Changed("format") {
format = cfg.Format
}
if cfg.Scanners != "" && !cmd.Flags().Changed("scanners") {
scanners = cfg.Scanners
}
if respectGitignore {
walk.RespectGitignore(root)
defer walk.RespectGitignore("")
}
sbomVersion, err := report.ParseCycloneDXVersion(cyclonedxVersion)
if err != nil {
return err
}
if format == "sbom" {
pkgs, err := manifest.Discover(root)
if err != nil {
return fmt.Errorf("discovering manifests: %w", err)
}
return report.SBOM(cmd.OutOrStdout(), pkgs, sbomVersion)
}
if gitlab {
scanners = "vuln,secret,misconfig,sast"
}
rulesDirPath := rulesDir
if rulesDirPath == "" {
rulesDirPath = filepath.Join(root, ".ojo", "rules")
} else if _, err := os.Stat(rulesDirPath); err != nil {
return fmt.Errorf("loading custom rules: %w", err)
}
customRules, err := customrules.Load(rulesDirPath)
if err != nil {
return fmt.Errorf("loading custom rules: %w", err)
}
extraSecretRules, err := secret.LoadRules(secretRulesFile)
if err != nil {
return fmt.Errorf("loading secret rules file: %w", err)
}
ignoreRules, err := ignore.Load(ignoreFile)
if err != nil {
return fmt.Errorf("loading ignore file: %w", err)
}
vexStatements, err := vex.Load(vexFile)
if err != nil {
return fmt.Errorf("loading VEX file: %w", err)
}
rep := report.Report{Target: root}
for _, s := range strings.Split(scanners, ",") {
switch strings.TrimSpace(s) {
case "vuln":
fmt.Fprintln(cmd.ErrOrStderr(), "Running vuln scan...")
pkgs, err := manifest.Discover(root)
if err != nil {
return fmt.Errorf("discovering manifests: %w", err)
}
findings, err := osvScan(cmd.Context(), pkgs)
if err != nil {
return fmt.Errorf("querying OSV: %w", err)
}
if kevFlag {
if err := annotateKEV(cmd, findings); err != nil {
return err
}
}
rep.Findings = findings
case "secret":
fmt.Fprintln(cmd.ErrOrStderr(), "Running secret scan...")
issues, err := secret.Scan(root, extraSecretRules)
if err != nil {
return fmt.Errorf("scanning secrets: %w", err)
}
rep.Issues = append(rep.Issues, issues...)
if secretGitHistory {
histIssues, err := secret.ScanGitHistory(cmd.Context(), root, extraSecretRules)
if err != nil {
return fmt.Errorf("scanning git history for secrets: %w", err)
}
rep.Issues = append(rep.Issues, histIssues...)
}
case "misconfig":
fmt.Fprintln(cmd.ErrOrStderr(), "Running misconfig scan...")
issues, err := misconfig.Scan(root)
if err != nil {
return fmt.Errorf("scanning misconfig: %w", err)
}
rep.Issues = append(rep.Issues, issues...)
case "sast":
fmt.Fprintln(cmd.ErrOrStderr(), "Running sast scan...")
issues, err := sast.Scan(root)
if err != nil {
return fmt.Errorf("running sast: %w", err)
}
rep.Issues = append(rep.Issues, issues...)
customIssues, err := customrules.Scan(root, customRules)
if err != nil {
return fmt.Errorf("running custom rules: %w", err)
}
rep.Issues = append(rep.Issues, customIssues...)
case "quality":
fmt.Fprintln(cmd.ErrOrStderr(), "Running quality scan...")
issues, err := quality.Scan(root)
if err != nil {
return fmt.Errorf("running quality: %w", err)
}
rep.Issues = append(rep.Issues, issues...)
case "":
// no-op, allows trailing commas
default:
return fmt.Errorf("unknown scanner %q (available: vuln, secret, misconfig, sast, quality)", s)
}
}
kept, suppressedFindings, keptIssues, suppressedIssues := ignore.Apply(rep.Findings, rep.Issues, ignoreRules, root, time.Now())
rep.Findings, rep.Issues = kept, keptIssues
rep.SuppressedFindings, rep.SuppressedIssues = suppressedFindings, suppressedIssues
if len(vexStatements) > 0 {
vexKept, vexSuppressed := vex.Apply(rep.Findings, vexStatements)
rep.Findings = vexKept
rep.SuppressedFindings = append(rep.SuppressedFindings, vexSuppressed...)
}
if gitlab {
pkgs, err := manifest.Discover(root)
if err != nil {
return fmt.Errorf("discovering manifests: %w", err)
}
files := []struct {
name string
write func(io.Writer) error
}{
{"gl-dependency-scanning-report.json", func(w io.Writer) error { return rep.GitLabDependencyScanning(w, root, Version) }},
{"gl-sast-report.json", func(w io.Writer) error { return rep.GitLabSAST(w, root, Version) }},
{"gl-secret-detection-report.json", func(w io.Writer) error { return rep.GitLabSecretDetection(w, root, Version) }},
{"gl-sbom-report.cdx.json", func(w io.Writer) error { return report.SBOM(w, pkgs, sbomVersion) }},
}
for _, f := range files {
out, err := os.Create(f.name)
if err != nil {
return fmt.Errorf("writing %s: %w", f.name, err)
}
err = f.write(out)
out.Close()
if err != nil {
return fmt.Errorf("writing %s: %w", f.name, err)
}
fmt.Fprintln(cmd.OutOrStdout(), "wrote", f.name)
}
} else {
switch format {
case "json":
if err := rep.JSON(cmd.OutOrStdout()); err != nil {
return err
}
case "sarif":
if err := rep.SARIFWith(cmd.OutOrStdout(), root, report.SARIFOptions{OmitSuppressed: sarifOmitSuppressed}); err != nil {
return err
}
case "vex":
doc := vex.Generate(rep.Findings, "ojo "+Version, time.Now())
if err := vex.Write(cmd.OutOrStdout(), doc); err != nil {
return err
}
default:
rep.Table(cmd.OutOrStdout(), root)
}
}
if len(rep.Findings) > 0 || len(rep.Issues) > 0 {
return ErrFindingsFound // non-zero exit on findings, matches trivy/CI scanner convention
}
return nil
},
}
cmd.Flags().StringVarP(&format, "format", "f", "table", "output format: table, json, sbom, sarif, vex")
cmd.Flags().StringVar(&scanners, "scanners", "vuln", "comma-separated scanners to run: vuln, secret, misconfig, sast, quality")
cmd.Flags().StringVar(&configPath, "config", "", "path to a .ojo.yaml config file (default: .ojo.yaml in the current directory, if present)")
cmd.Flags().BoolVarP(&gitlab, "gitlab", "g", false, "write GitLab-compatible security reports (gl-dependency-scanning-report.json, gl-sast-report.json, gl-secret-detection-report.json, gl-sbom-report.cdx.json) instead of -f/--format output; runs all scanners")
cmd.Flags().StringVar(&rulesDir, "rules-dir", "", "directory of custom *.yaml SAST rules (default: <path>/.ojo/rules, if present); runs alongside --scanners sast")
cmd.Flags().StringVar(&ignoreFile, "ignore-file", "", "path to a .ojoignore file (default: .ojoignore in the current directory, if present)")
cmd.Flags().StringVar(&cyclonedxVersion, "cyclonedx-version", "", "CycloneDX spec version for -f sbom output, e.g. 1.4 (default: latest)")
cmd.Flags().StringVar(&secretRulesFile, "secret-rules-file", "", "path to a YAML file of additional secret rules (same shape as the built-in rules), run alongside --scanners secret")
cmd.Flags().BoolVar(&secretGitHistory, "secret-git-history", false, "also scan git commit history (current branch) for secrets that were committed and later removed; requires root to be a git repository")
cmd.Flags().BoolVar(&kevFlag, "kev", false, "flag findings whose CVE is in CISA's Known Exploited Vulnerabilities catalog (confirmed real-world exploitation); annotation only, doesn't affect exit code")
cmd.Flags().BoolVar(&sarifOmitSuppressed, "sarif-omit-suppressed", false, "omit results suppressed by .ojoignore or a VEX file from -f sarif output instead of marking them suppressed; for consumers such as GitHub code scanning that ignore SARIF suppressions")
cmd.Flags().BoolVar(&respectGitignore, "respect-gitignore", false, "skip untracked files that git ignores (e.g. build output, coverage reports); no effect outside a git repository")
cmd.Flags().StringVar(&vexFile, "vex-file", "", "path to an OpenVEX document; suppresses findings its not_affected/fixed statements cover (matched by product purl and CVE/alias)")
return cmd
}
package cli
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/colibrisec/ojo/internal/config"
"github.com/colibrisec/ojo/internal/ignore"
"github.com/colibrisec/ojo/internal/report"
"github.com/colibrisec/ojo/internal/vex"
)
func imageCmd() *cobra.Command {
var format string
var configPath string
var ignoreFile string
var platform string
var cyclonedxVersion string
var kevFlag bool
var vexFile string
var sarifOmitSuppressed bool
cmd := &cobra.Command{
Use: "image [ref]",
Short: "Scan a container image for vulnerable OS and Node.js packages",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ref := args[0]
cfg, err := config.Load(configPath)
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if cfg.Format != "" && !cmd.Flags().Changed("format") {
format = cfg.Format
}
sbomVersion, err := report.ParseCycloneDXVersion(cyclonedxVersion)
if err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "Pulling and scanning image layers...")
pkgs, osLabel, err := imageScan(cmd.Context(), ref, platform)
if err != nil {
return err
}
if len(pkgs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No packages found.")
return nil
}
if format == "sbom" {
return report.SBOM(cmd.OutOrStdout(), pkgs, sbomVersion)
}
fmt.Fprintln(cmd.ErrOrStderr(), "Querying OSV for known vulnerabilities...")
findings, err := osvScan(cmd.Context(), pkgs)
if err != nil {
return fmt.Errorf("querying OSV: %w", err)
}
if kevFlag {
if err := annotateKEV(cmd, findings); err != nil {
return err
}
}
ignoreRules, err := ignore.Load(ignoreFile)
if err != nil {
return fmt.Errorf("loading ignore file: %w", err)
}
vexStatements, err := vex.Load(vexFile)
if err != nil {
return fmt.Errorf("loading VEX file: %w", err)
}
kept, suppressed, _, _ := ignore.Apply(findings, nil, ignoreRules, "", time.Now())
findings = kept
if len(vexStatements) > 0 {
vexKept, vexSuppressed := vex.Apply(findings, vexStatements)
findings = vexKept
suppressed = append(suppressed, vexSuppressed...)
}
rep := report.Report{Target: fmt.Sprintf("%s (%s)", ref, osLabel), Findings: findings, SuppressedFindings: suppressed}
switch format {
case "json":
if err := rep.JSON(cmd.OutOrStdout()); err != nil {
return err
}
case "sarif":
if err := rep.SARIFWith(cmd.OutOrStdout(), "", report.SARIFOptions{OmitSuppressed: sarifOmitSuppressed}); err != nil {
return err
}
case "vex":
doc := vex.Generate(findings, "ojo "+Version, time.Now())
if err := vex.Write(cmd.OutOrStdout(), doc); err != nil {
return err
}
default:
rep.Table(cmd.OutOrStdout(), "")
}
if len(findings) > 0 {
return ErrFindingsFound
}
return nil
},
}
cmd.Flags().StringVarP(&format, "format", "f", "table", "output format: table, json, sbom, sarif, vex")
cmd.Flags().StringVar(&configPath, "config", "", "path to a .ojo.yaml config file (default: .ojo.yaml in the current directory, if present)")
cmd.Flags().StringVar(&ignoreFile, "ignore-file", "", "path to a .ojoignore file (default: .ojoignore in the current directory, if present)")
cmd.Flags().StringVar(&platform, "platform", "", "image platform to pull as os/arch, e.g. linux/arm64 (default: linux/amd64)")
cmd.Flags().StringVar(&cyclonedxVersion, "cyclonedx-version", "", "CycloneDX spec version for -f sbom output, e.g. 1.4 (default: latest)")
cmd.Flags().BoolVar(&kevFlag, "kev", false, "flag findings whose CVE is in CISA's Known Exploited Vulnerabilities catalog (confirmed real-world exploitation); annotation only, doesn't affect exit code")
cmd.Flags().BoolVar(&sarifOmitSuppressed, "sarif-omit-suppressed", false, "omit results suppressed by .ojoignore or a VEX file from -f sarif output instead of marking them suppressed; for consumers such as GitHub code scanning that ignore SARIF suppressions")
cmd.Flags().StringVar(&vexFile, "vex-file", "", "path to an OpenVEX document; suppresses findings its not_affected/fixed statements cover (matched by product purl and CVE/alias)")
return cmd
}
package cli
import (
"fmt"
"github.com/spf13/cobra"
"github.com/colibrisec/ojo/internal/kev"
"github.com/colibrisec/ojo/internal/model"
)
// annotateKEV loads the CISA KEV catalog (cached ~/.cache/ojo/kev.json,
// refetched once a day) and marks findings whose CVE is in it. A failed
// fetch with no usable cache is returned as an error -- the user explicitly
// asked for KEV data via --kev, so silently skipping it would hide that it
// didn't happen; a fetch failure with a stale cache available prints a
// warning to stderr instead, since stale KEV data is still useful.
func annotateKEV(cmd *cobra.Command, findings []model.Finding) error {
set, stale, err := kevLoad(kev.DefaultCachePath())
if err != nil {
return fmt.Errorf("loading KEV catalog: %w", err)
}
if stale {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: could not refresh the CISA KEV catalog, using a cached copy that may be out of date")
}
kev.Annotate(findings, set)
return nil
}
// Package cli wires ojo's cobra commands.
package cli
import (
"errors"
"github.com/spf13/cobra"
)
var Version = "dev"
// ErrFindingsFound signals "exit 1, print nothing" -- fs/image return it
// instead of calling os.Exit directly, so RunE stays testable in-process.
var ErrFindingsFound = errors.New("findings found")
func Root() *cobra.Command {
root := &cobra.Command{
Use: "ojo",
Short: "ojo is a security scanner for dependencies, secrets, misconfig, and code",
// Errors are printed exactly once, by main.go -- without these,
// cobra's own default error printing would double up with it.
SilenceErrors: true,
SilenceUsage: true,
Long: `ojo is a security scanner for dependencies, secrets, misconfig, and code.
Scanners (--scanners, comma-separated, ojo fs only):
vuln known CVEs in dependency manifests (default)
secret hardcoded credentials, API keys, tokens
misconfig Dockerfile / Kubernetes / Terraform misconfiguration
sast source-level issues (Go, Python, JS/TS, PHP, Ruby, Java)
quality maintainability smells: complexity, length, nesting, params, duplication
Output formats (-f/--format, both commands):
table human-readable box-drawn table (default)
json machine-readable
sbom CycloneDX SBOM of discovered packages, skips vulnerability scanning
sarif SARIF 2.1.0, for GitHub code scanning and similar tooling`,
Example: ` ojo fs .
ojo fs --scanners vuln,secret,misconfig,sast,quality .
ojo fs -f sarif . > results.sarif
ojo fs -g .
ojo image python:3.14-slim`,
Version: Version,
}
root.AddCommand(fsCmd())
root.AddCommand(imageCmd())
return root
}
// Package config loads optional .ojo.yaml defaults for CLI flags.
package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"gopkg.in/yaml.v3"
)
// Config mirrors a subset of ojo's CLI flags for use as per-repo defaults.
type Config struct {
Scanners string `yaml:"scanners"`
Format string `yaml:"format"`
}
// Load reads explicitPath, or ".ojo.yaml" in the current directory if
// explicitPath is empty. A missing default file is not an error (no
// overrides); a missing explicit path is, since that's almost certainly a
// typo. Unrecognized keys are also an error, for the same reason.
func Load(explicitPath string) (*Config, error) {
path := explicitPath
if path == "" {
path = ".ojo.yaml"
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) && explicitPath == "" {
return &Config{}, nil
}
return nil, err
}
var c Config
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&c); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return &c, nil
}
// Package customrules loads user-authored SAST rules from YAML files and
// runs them alongside ojo's built-in rules.
//
// ponytail ceiling: a rule's "query" field is a raw tree-sitter S-expression
// query — the exact same query language internal/sast's own built-in rules
// are written in, not a friendlier Semgrep-style `pattern: eval($X)`
// syntax. That's a deliberate scope decision (see TODO.md's phase-2 note),
// not an oversight: it reuses the query engine directly instead of building
// a pattern-string-to-query compiler (parsing a code snippet, walking its
// AST, turning metavariable identifiers into captures — a real subsystem
// on its own). The tradeoff is a more expert-facing authoring experience in
// exchange for shipping something that's exactly as accurate as what the
// engine actually does, with no separate compiler to keep in sync.
//
// Go has no custom rules: its built-in rules are hand-rolled go/ast
// predicates with no query layer to hang a YAML-driven rule off of.
package customrules
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"gopkg.in/yaml.v3"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
// Rule is one user-authored rule loaded from a YAML file. The query must
// contain a capture named @match — the node used as the finding's
// file/line location — matching nothing produces no findings, but a query
// with no @match capture at all is a load-time error (see validate).
type Rule struct {
ID string `yaml:"id"`
Language string `yaml:"language"`
Severity string `yaml:"severity"`
Title string `yaml:"title"`
Message string `yaml:"message"`
Query string `yaml:"query"`
lang *gts.Language
query *gts.Query
}
var languages = map[string]*gts.Language{
"python": grammars.PythonLanguage(),
"javascript": grammars.JavascriptLanguage(),
"typescript": grammars.TypescriptLanguage(),
"tsx": grammars.TsxLanguage(),
"php": grammars.PhpLanguage(),
"ruby": grammars.RubyLanguage(),
"java": grammars.JavaLanguage(),
}
// extsFor mirrors internal/sast's own per-language extension handling
// (jsLangForPath in scanner.go) — kept in sync by hand since the two
// packages don't share this mapping.
var extsFor = map[string][]string{
"python": {".py"},
"javascript": {".js", ".jsx", ".mjs", ".cjs"},
"typescript": {".ts", ".mts", ".cts"},
"tsx": {".tsx"},
"php": {".php"},
"ruby": {".rb"},
"java": {".java"},
}
var validSeverities = map[string]bool{
"CRITICAL": true, "HIGH": true, "MEDIUM": true, "LOW": true, "INFO": true,
}
// Load reads every *.yaml/*.yml file directly inside dir (not recursive) as
// a Rule. A dir that doesn't exist is not an error — no custom rules,
// same "absent means off" policy as .ojo.yaml — but an existing dir
// containing an invalid rule file is.
func Load(dir string) ([]Rule, error) {
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var names []string
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(e.Name(), ".yaml") || strings.HasSuffix(e.Name(), ".yml") {
names = append(names, e.Name())
}
}
sort.Strings(names) // deterministic load order regardless of directory listing order
seen := map[string]string{} // id -> file that defined it, for the duplicate-id error message
var rules []Rule
for _, name := range names {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var r Rule
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&r); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if err := r.validate(); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if prior, ok := seen[r.ID]; ok {
return nil, fmt.Errorf("%s: rule id %q already defined in %s", path, r.ID, prior)
}
seen[r.ID] = path
r.lang = languages[r.Language]
q, err := gts.NewQuery(r.Query, r.lang)
if err != nil {
return nil, fmt.Errorf("%s: invalid query: %w", path, err)
}
r.query = q
rules = append(rules, r)
}
return rules, nil
}
func (r Rule) validate() error {
if r.ID == "" {
return fmt.Errorf("missing id")
}
if _, ok := languages[r.Language]; !ok {
return fmt.Errorf("unknown language %q (want one of: python, javascript, typescript, tsx, php, ruby, java)", r.Language)
}
if !validSeverities[r.Severity] {
return fmt.Errorf("unknown severity %q (want one of: CRITICAL, HIGH, MEDIUM, LOW, INFO)", r.Severity)
}
if strings.TrimSpace(r.Query) == "" {
return fmt.Errorf("missing query")
}
if r.Message == "" {
return fmt.Errorf("missing message")
}
if !strings.Contains(r.Query, "@match") {
return fmt.Errorf("query has no @match capture — that's what a finding's file/line location is taken from")
}
return nil
}
// Scan runs every rule against every file under root whose extension
// matches that rule's language, parsing each file once per language even
// when multiple rules share it.
func Scan(root string, rules []Rule) ([]model.Issue, error) {
if len(rules) == 0 {
return nil, nil
}
byExt := map[string][]Rule{}
for _, r := range rules {
for _, ext := range extsFor[r.Language] {
byExt[ext] = append(byExt[ext], r)
}
}
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
matching := byExt[filepath.Ext(path)]
if len(matching) == 0 {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil // ponytail: skip unreadable files, don't fail the whole scan
}
lang := matching[0].lang // every rule in matching shares one language (grouped by extsFor)
tree, err := gts.NewParser(lang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
root := tree.RootNode()
for _, r := range matching {
for _, m := range r.query.ExecuteNode(root, lang, src) {
var match *gts.Node
for _, c := range m.Captures {
if c.Name == "match" {
match = c.Node
}
}
if match == nil {
continue
}
issues = append(issues, model.Issue{
Scanner: "sast",
RuleID: r.ID,
Title: r.Title,
Severity: r.Severity,
File: path,
Line: int(match.StartPoint().Row) + 1,
Message: r.Message,
})
}
}
return nil
})
return issues, err
}
// Package ignore parses .ojoignore files for accepting risk on specific
// findings/issues without editing scanner code, and applies them to a scan's
// results.
package ignore
import (
"bufio"
"bytes"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/colibrisec/ojo/internal/model"
)
// Rule is one .ojoignore entry: suppress a finding/issue whose ID matches ID
// and whose path matches PathGlob, until Expires (zero means never).
type Rule struct {
ID string
PathGlob string
Reason string
Expires time.Time
}
var expiresRe = regexp.MustCompile(`\(expires:\s*([^)]*)\)\s*$`)
// Load reads explicitPath, or ".ojoignore" in the current directory if
// explicitPath is empty. A missing default file is not an error (nothing
// ignored); a missing explicit path is, since that's almost certainly a typo.
//
// Each non-blank, non-comment line is:
//
// <id> <path-glob> # reason (expires: 2026-12-31)
//
// id matches a Vulnerability's ID/alias or an Issue's RuleID exactly.
// path-glob is matched with path.Match against the finding/issue's
// "/"-separated path relative to the scan root, so "*" spans one path
// segment. A reason is
// required; "(expires: YYYY-MM-DD)" at the end of the reason is optional —
// once past that date the entry stops suppressing instead of erroring.
func Load(explicitPath string) ([]Rule, error) {
path := explicitPath
if path == "" {
path = ".ojoignore"
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) && explicitPath == "" {
return nil, nil
}
return nil, err
}
var rules []Rule
sc := bufio.NewScanner(bytes.NewReader(data))
for lineNo := 1; sc.Scan(); lineNo++ {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
rule, err := parseLine(line)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", path, lineNo, err)
}
rules = append(rules, rule)
}
if err := sc.Err(); err != nil {
return nil, err
}
return rules, nil
}
func parseLine(line string) (Rule, error) {
fields, reason, ok := strings.Cut(line, "#")
if !ok || strings.TrimSpace(reason) == "" {
return Rule{}, fmt.Errorf(`missing required reason, expected "<id> <path-glob> # reason": %q`, line)
}
parts := strings.Fields(fields)
if len(parts) != 2 {
return Rule{}, fmt.Errorf(`expected "<id> <path-glob> # reason", got %q`, line)
}
rule := Rule{ID: parts[0], PathGlob: parts[1]}
reason = strings.TrimSpace(reason)
if m := expiresRe.FindStringSubmatch(reason); m != nil {
expires, err := time.Parse("2006-01-02", m[1])
if err != nil {
return Rule{}, fmt.Errorf("invalid expires date %q: %w", m[1], err)
}
rule.Expires = expires
reason = strings.TrimSpace(reason[:len(reason)-len(m[0])])
}
if reason == "" {
return Rule{}, fmt.Errorf(`missing required reason, expected "<id> <path-glob> # reason": %q`, line)
}
rule.Reason = reason
return rule, nil
}
// Matches reports whether the rule suppresses id at path as of now. path is
// matched with the "/"-separated path.Match (not path/filepath.Match), which
// treats "/" as the separator on every OS ojo runs on, including Windows.
func (r Rule) Matches(id, p string, now time.Time) bool {
if r.ID != id || (!r.Expires.IsZero() && !now.Before(r.Expires)) {
return false
}
ok, _ := path.Match(r.PathGlob, filepath.ToSlash(p))
return ok
}
// SuppressedFinding is a Finding vulnerability matched by a .ojoignore rule.
type SuppressedFinding struct {
Package model.Package
Vuln model.Vulnerability
Reason string
}
// SuppressedIssue is an Issue matched by a .ojoignore rule.
type SuppressedIssue struct {
Issue model.Issue
Reason string
}
// Apply splits findings and issues into what's kept and what a rule
// suppresses. A vulnerability is matched by its ID or any alias; an issue by
// its RuleID. A Finding whose every vulnerability is suppressed is dropped
// entirely; one with only some suppressed keeps the rest.
func Apply(findings []model.Finding, issues []model.Issue, rules []Rule, root string, now time.Time) (keptFindings []model.Finding, suppressedFindings []SuppressedFinding, keptIssues []model.Issue, suppressedIssues []SuppressedIssue) {
for _, f := range findings {
path := relSlash(root, f.Package.Source)
var kept []model.Vulnerability
for _, v := range f.Vulns {
if reason, ok := matchVuln(rules, v, path, now); ok {
suppressedFindings = append(suppressedFindings, SuppressedFinding{Package: f.Package, Vuln: v, Reason: reason})
} else {
kept = append(kept, v)
}
}
if len(kept) > 0 {
f.Vulns = kept
keptFindings = append(keptFindings, f)
}
}
for _, iss := range issues {
path := relSlash(root, iss.File)
if reason, ok := matchID(rules, iss.RuleID, path, now); ok {
suppressedIssues = append(suppressedIssues, SuppressedIssue{Issue: iss, Reason: reason})
} else {
keptIssues = append(keptIssues, iss)
}
}
return
}
func matchVuln(rules []Rule, v model.Vulnerability, path string, now time.Time) (string, bool) {
if reason, ok := matchID(rules, v.ID, path, now); ok {
return reason, true
}
for _, alias := range v.Aliases {
if reason, ok := matchID(rules, alias, path, now); ok {
return reason, true
}
}
return "", false
}
func matchID(rules []Rule, id, path string, now time.Time) (string, bool) {
for _, r := range rules {
if r.Matches(id, path, now) {
return r.Reason, true
}
}
return "", false
}
func relSlash(root, path string) string {
if root != "" {
if rel, err := filepath.Rel(root, path); err == nil {
path = rel
}
}
return filepath.ToSlash(path)
}
package image
import (
"bufio"
"bytes"
"github.com/colibrisec/ojo/internal/model"
)
func parseApk(data []byte, ecosystem model.Ecosystem) []model.Package {
var pkgs []model.Package
var name, version, origin string
flush := func() {
if name != "" && version != "" {
pkgs = append(pkgs, model.Package{Name: name, Version: version, Origin: origin, Ecosystem: ecosystem, Source: "apk"})
}
name, version, origin = "", "", ""
}
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := scanner.Text()
if line == "" {
flush()
continue
}
if len(line) < 2 || line[1] != ':' {
continue
}
switch line[0] {
case 'P':
name = line[2:]
case 'V':
version = line[2:]
case 'o':
origin = line[2:]
}
}
flush()
return pkgs
}
package image
import (
"bufio"
"bytes"
"net/textproto"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
func parseDpkg(data []byte, ecosystem model.Ecosystem) []model.Package {
var pkgs []model.Package
reader := textproto.NewReader(bufio.NewReader(bytes.NewReader(data)))
for {
header, err := reader.ReadMIMEHeader()
if len(header) == 0 && err != nil {
break
}
status := header.Get("Status")
if !strings.Contains(status, "installed") {
continue
}
name := header.Get("Package")
version := header.Get("Version")
if name != "" && version != "" {
var origin string
if f := strings.Fields(header.Get("Source")); len(f) > 0 {
origin = f[0]
}
pkgs = append(pkgs, model.Package{Name: name, Version: version, Origin: origin, Ecosystem: ecosystem, Source: "dpkg"})
}
if err != nil {
break
}
}
return pkgs
}
package image
import (
"encoding/json"
"regexp"
"github.com/colibrisec/ojo/internal/model"
)
const maxPackageJSONSize = 1 << 20
var nodePackageJSONRe = regexp.MustCompile(`(^|/)node_modules/(@[^/]+/)?[^/@.][^/]*/package\.json$`)
func nodePackage(path string, data []byte) (model.Package, bool) {
if !nodePackageJSONRe.MatchString(path) {
return model.Package{}, false
}
var pj struct {
Name string `json:"name"`
Version string `json:"version"`
}
if err := json.Unmarshal(data, &pj); err != nil || pj.Name == "" || pj.Version == "" {
return model.Package{}, false
}
return model.Package{Name: pj.Name, Version: pj.Version, Ecosystem: model.EcosystemNpm, Source: path}, true
}
package image
import (
"bufio"
"bytes"
"strconv"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
func parseOSRelease(data []byte) map[string]string {
info := make(map[string]string)
scanner := bufio.NewScanner(bytes.NewReader(data))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
info[k] = strings.Trim(v, `"'`)
}
return info
}
var rpmDistros = map[string]bool{"rhel": true, "centos": true, "fedora": true, "amzn": true, "rocky": true, "almalinux": true}
func isRPMBased(info map[string]string) bool {
if rpmDistros[info["ID"]] {
return true
}
for _, like := range strings.Fields(info["ID_LIKE"]) {
if rpmDistros[like] {
return true
}
}
return false
}
func osEcosystem(info map[string]string) model.Ecosystem {
id := info["ID"]
version := info["VERSION_ID"]
switch id {
case "alpine":
parts := strings.SplitN(version, ".", 3)
if len(parts) >= 2 {
version = parts[0] + "." + parts[1]
}
return model.Ecosystem("Alpine:v" + version)
case "debian":
return model.Ecosystem("Debian:" + version)
case "ubuntu":
if isUbuntuLTS(version) {
return model.Ecosystem("Ubuntu:" + version + ":LTS")
}
return model.Ecosystem("Ubuntu:" + version)
default:
return model.Ecosystem(id)
}
}
func isUbuntuLTS(version string) bool {
year, month, ok := strings.Cut(version, ".")
if !ok || month != "04" {
return false
}
y, err := strconv.Atoi(year)
return err == nil && y%2 == 0
}
// Package image scans a container image's OS package databases for
// vulnerable packages, producing model.Package values that feed the same
// OSV pipeline used for filesystem dependency scanning.
package image
import (
"context"
"fmt"
"io"
"strings"
"github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
)
// parsePlatform parses a "--platform os/arch" value. "" means the default,
// linux/amd64.
func parsePlatform(s string) (v1.Platform, error) {
if s == "" {
return v1.Platform{OS: "linux", Architecture: "amd64"}, nil
}
os, arch, ok := strings.Cut(s, "/")
if !ok || os == "" || arch == "" {
return v1.Platform{}, fmt.Errorf("invalid --platform %q, expected \"os/arch\" (e.g. linux/arm64)", s)
}
return v1.Platform{OS: os, Architecture: arch}, nil
}
func extractFS(ctx context.Context, ref, platform string) (io.ReadCloser, error) {
r, err := name.ParseReference(ref)
if err != nil {
return nil, err
}
plat, err := parsePlatform(platform)
if err != nil {
return nil, err
}
img, err := remote.Image(r,
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
remote.WithPlatform(plat),
)
if err != nil {
return nil, err
}
return mutate.Extract(img), nil
}
package image
import (
"archive/tar"
"context"
"fmt"
"io"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
func Scan(ctx context.Context, ref, platform string) ([]model.Package, string, error) {
rc, err := extractFS(ctx, ref, platform)
if err != nil {
return nil, "", fmt.Errorf("pulling %s: %w", ref, err)
}
defer rc.Close()
return scanFS(rc, ref)
}
func scanFS(r io.Reader, ref string) ([]model.Package, string, error) {
files, err := readImageFS(tar.NewReader(r))
if err != nil {
return nil, "", fmt.Errorf("reading image filesystem: %w", err)
}
info := parseOSRelease(files.osRelease)
eco := osEcosystem(info)
if eco == "" {
return nil, "", fmt.Errorf("could not determine OS/version for %s (no os-release found); cannot safely scope an OSV query", ref)
}
osLabel := strings.TrimSpace(info["ID"] + " " + info["VERSION_ID"])
var pkgs []model.Package
switch {
case files.apkDB != nil:
pkgs = parseApk(files.apkDB, eco)
case files.dpkgStatus != nil:
pkgs = parseDpkg(files.dpkgStatus, eco)
case isRPMBased(info):
return nil, "", fmt.Errorf("rpm-based image (%s): rpm package scanning is not supported yet", info["ID"])
}
return append(pkgs, dedupeNpm(files.npm)...), osLabel, nil
}
func dedupeNpm(pkgs []model.Package) []model.Package {
seen := map[string]bool{}
var out []model.Package
for _, p := range pkgs {
key := p.Name + "@" + p.Version
if seen[key] {
continue
}
seen[key] = true
out = append(out, p)
}
return out
}
type imageFiles struct {
osRelease, apkDB, dpkgStatus []byte
npm []model.Package
}
func readImageFS(tr *tar.Reader) (imageFiles, error) {
var files imageFiles
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return imageFiles{}, err
}
name := cleanPath(hdr.Name)
switch name {
case "etc/os-release", "usr/lib/os-release":
if b, err := io.ReadAll(tr); err == nil && len(b) > 0 {
files.osRelease = b
}
case "lib/apk/db/installed":
files.apkDB, _ = io.ReadAll(tr)
case "var/lib/dpkg/status":
files.dpkgStatus, _ = io.ReadAll(tr)
default:
if hdr.Typeflag != tar.TypeReg || !nodePackageJSONRe.MatchString(name) {
continue
}
if b, err := io.ReadAll(io.LimitReader(tr, maxPackageJSONSize)); err == nil {
if pkg, ok := nodePackage(name, b); ok {
files.npm = append(files.npm, pkg)
}
}
}
}
return files, nil
}
func cleanPath(name string) string {
name = strings.ReplaceAll(name, `\`, "/")
return strings.TrimPrefix(strings.TrimPrefix(name, "./"), "/")
}
// Package kev cross-references vulnerability findings against CISA's Known
// Exploited Vulnerabilities catalog. A CVE having a KEV entry means it has
// confirmed real-world exploitation -- a stronger, more concrete signal
// than CVSS severity alone, which only estimates how bad exploitation
// would be, not whether it's actually happening.
package kev
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/colibrisec/ojo/internal/model"
)
var feedURL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
// cacheTTL: KEV entries are added a few times a week at most, not
// continuously -- a day-old cache is never meaningfully stale for this
// data. ponytail: fixed TTL, not configurable; add a flag if a shorter
// window turns out to matter.
const cacheTTL = 24 * time.Hour
var httpClient = &http.Client{Timeout: 30 * time.Second}
type Entry struct {
DateAdded string
Ransomware bool
}
// Set is the KEV catalog keyed by CVE ID for O(1) lookup.
type Set map[string]Entry
type catalogEntry struct {
CVEID string `json:"cveID"`
DateAdded string `json:"dateAdded"`
KnownRansomwareCampaignUse string `json:"knownRansomwareCampaignUse"`
}
type catalog struct {
Vulnerabilities []catalogEntry `json:"vulnerabilities"`
}
// DefaultCachePath returns where Load caches the catalog between runs
// (~/.cache/ojo/kev.json, following XDG on Linux via os.UserCacheDir). ""
// means caching is unavailable on this system -- Load still works, it just
// fetches fresh every call.
func DefaultCachePath() string {
dir, err := os.UserCacheDir()
if err != nil {
return ""
}
return filepath.Join(dir, "ojo", "kev.json")
}
// Load returns the KEV catalog, from cachePath if it's younger than
// cacheTTL, otherwise freshly fetched (and, if cachePath != "", cached for
// next time). If the fetch fails, a still-present-but-stale cache is used
// instead of failing outright -- stale > nothing for enrichment data that
// doesn't gate the scan's exit code, and CISA's feed being briefly
// unreachable shouldn't fail an otherwise-successful vulnerability scan.
// stale reports whether the returned catalog came from an expired cache
// after a failed refetch, so the caller can warn about it.
func Load(cachePath string) (set Set, stale bool, err error) {
if cachePath != "" {
if data, fresh, ok := readCache(cachePath); ok && fresh {
return toSet(data), false, nil
}
}
data, fetchErr := fetch()
if fetchErr == nil {
if cachePath != "" {
_ = writeCache(cachePath, data) // ponytail: cache-write failure isn't fatal, just means no caching this run
}
return toSet(data), false, nil
}
if cachePath != "" {
if data, _, ok := readCache(cachePath); ok {
return toSet(data), true, nil
}
}
return nil, false, fmt.Errorf("fetching KEV catalog: %w", fetchErr)
}
func fetch() (catalog, error) {
resp, err := httpClient.Get(feedURL)
if err != nil {
return catalog{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return catalog{}, fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var c catalog
if err := json.NewDecoder(resp.Body).Decode(&c); err != nil {
return catalog{}, err
}
return c, nil
}
func readCache(path string) (c catalog, fresh bool, ok bool) {
info, err := os.Stat(path)
if err != nil {
return catalog{}, false, false
}
f, err := os.Open(path)
if err != nil {
return catalog{}, false, false
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return catalog{}, false, false
}
if err := json.Unmarshal(data, &c); err != nil {
return catalog{}, false, false
}
return c, time.Since(info.ModTime()) < cacheTTL, true
}
func writeCache(path string, c catalog) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
data, err := json.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
func toSet(c catalog) Set {
set := make(Set, len(c.Vulnerabilities))
for _, e := range c.Vulnerabilities {
set[e.CVEID] = Entry{DateAdded: e.DateAdded, Ransomware: e.KnownRansomwareCampaignUse == "Known"}
}
return set
}
// Annotate sets Vulnerability.KEV/KEVDateAdded on every finding whose ID or
// any alias is in set. Matching by alias too matters because OSV's
// preferred ID for a vulnerability isn't always its CVE ID -- the KEV
// catalog only speaks CVE.
func Annotate(findings []model.Finding, set Set) {
for i := range findings {
for j := range findings[i].Vulns {
v := &findings[i].Vulns[j]
if e, ok := set[v.ID]; ok {
v.KEV, v.KEVDateAdded = true, e.DateAdded
continue
}
for _, alias := range v.Aliases {
if e, ok := set[alias]; ok {
v.KEV, v.KEVDateAdded = true, e.DateAdded
break
}
}
}
}
}
package manifest
import "github.com/colibrisec/ojo/internal/model"
type cargoLockParser struct{}
func (cargoLockParser) Match(name string) bool { return name == "Cargo.lock" }
func (cargoLockParser) Parse(path string) ([]model.Package, error) {
return parseTomlPackages(path, model.EcosystemCratesIO)
}
package manifest
import (
"encoding/json"
"os"
"github.com/colibrisec/ojo/internal/model"
)
type composerLockParser struct{}
func (composerLockParser) Match(name string) bool { return name == "composer.lock" }
type composerLockFile struct {
Packages []composerPackage `json:"packages"`
PackagesDev []composerPackage `json:"packages-dev"`
}
type composerPackage struct {
Name string `json:"name"`
Version string `json:"version"`
}
func (composerLockParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock composerLockFile
if err := json.Unmarshal(data, &lock); err != nil {
return nil, err
}
pkgs := make([]model.Package, 0, len(lock.Packages)+len(lock.PackagesDev))
for _, p := range append(lock.Packages, lock.PackagesDev...) {
if p.Name == "" || p.Version == "" {
continue
}
pkgs = append(pkgs, model.Package{
Name: p.Name, Version: trimVersionPrefix(p.Version), Ecosystem: model.EcosystemPackagist, Source: path,
})
}
return pkgs, nil
}
func trimVersionPrefix(v string) string {
if len(v) > 1 && (v[0] == 'v' || v[0] == 'V') && v[1] >= '0' && v[1] <= '9' {
return v[1:]
}
return v
}
package manifest
import (
"bufio"
"os"
"regexp"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type gemfileLockParser struct{}
func (gemfileLockParser) Match(name string) bool { return name == "Gemfile.lock" }
var specLineRe = regexp.MustCompile(`^ {4}(\S+) \(([^)]+)\)`)
func (gemfileLockParser) Parse(path string) ([]model.Package, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var pkgs []model.Package
inSpecs := false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
switch {
case strings.TrimRight(line, " ") == " specs:":
inSpecs = true
case !inSpecs:
// not in a specs block, nothing to do
case line == "" || indentOf(line) <= 2:
inSpecs = false
case indentOf(line) == 4:
if m := specLineRe.FindStringSubmatch(line); m != nil {
pkgs = append(pkgs, model.Package{Name: m[1], Version: m[2], Ecosystem: model.EcosystemRubyGems, Source: path})
}
}
}
return pkgs, scanner.Err()
}
func indentOf(line string) int {
return len(line) - len(strings.TrimLeft(line, " "))
}
package manifest
import (
"os"
"strings"
"golang.org/x/mod/modfile"
"github.com/colibrisec/ojo/internal/model"
)
type goModParser struct{}
func (goModParser) Match(name string) bool { return name == "go.mod" }
func (goModParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
f, err := modfile.Parse(path, data, nil)
if err != nil {
return nil, err
}
pkgs := make([]model.Package, 0, len(f.Require))
for _, req := range f.Require {
pkgs = append(pkgs, model.Package{
Name: req.Mod.Path,
Version: strings.TrimPrefix(req.Mod.Version, "v"),
Ecosystem: model.EcosystemGo,
Source: path,
})
}
return pkgs, nil
}
package manifest
import (
"bufio"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type gradleLockParser struct{}
func (gradleLockParser) Match(name string) bool { return name == "gradle.lockfile" }
func (gradleLockParser) Parse(path string) ([]model.Package, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var pkgs []model.Package
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "empty=") {
continue
}
coord, _, _ := strings.Cut(line, "=")
parts := strings.Split(coord, ":")
if len(parts) != 3 {
continue
}
group, artifact, version := parts[0], parts[1], parts[2]
pkgs = append(pkgs, model.Package{
Name: group + ":" + artifact, Version: version, Ecosystem: model.EcosystemMaven, Source: path,
})
}
return pkgs, scanner.Err()
}
// Package manifest discovers and parses dependency manifests/lockfiles into model.Package lists.
package manifest
import (
"io/fs"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
type Parser interface {
Match(name string) bool
Parse(path string) ([]model.Package, error)
}
var parsers = []Parser{
goModParser{},
npmLockParser{},
pipRequirementsParser{},
composerLockParser{},
pipfileLockParser{},
nugetLockParser{},
pubspecLockParser{},
cargoLockParser{},
poetryLockParser{},
gemfileLockParser{},
gradleLockParser{},
mavenPomParser{},
swiftPackageResolvedParser{},
}
func Discover(root string) ([]model.Package, error) {
var pkgs []model.Package
seen := map[model.Package]bool{}
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
for _, p := range parsers {
if p.Match(d.Name()) {
found, perr := p.Parse(path)
if perr != nil {
continue // ponytail: skip unparsable manifest, don't fail the whole scan
}
for _, pkg := range found {
dedupeKey := pkg
dedupeKey.Source = "" // same package via a different file is still a duplicate
if seen[dedupeKey] {
continue
}
seen[dedupeKey] = true
pkgs = append(pkgs, pkg)
}
}
}
return nil
})
return pkgs, err
}
package manifest
import (
"encoding/xml"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type mavenPomParser struct{}
func (mavenPomParser) Match(name string) bool { return name == "pom.xml" }
type pomProject struct {
Properties pomProperties `xml:"properties"`
Dependencies []pomDependency `xml:"dependencies>dependency"`
}
type pomProperties struct {
Items []pomProperty `xml:",any"`
}
type pomProperty struct {
XMLName xml.Name
Value string `xml:",chardata"`
}
type pomDependency struct {
GroupID string `xml:"groupId"`
ArtifactID string `xml:"artifactId"`
Version string `xml:"version"`
}
func (mavenPomParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var proj pomProject
if err := xml.Unmarshal(data, &proj); err != nil {
return nil, err
}
props := make(map[string]string, len(proj.Properties.Items))
for _, p := range proj.Properties.Items {
props[p.XMLName.Local] = strings.TrimSpace(p.Value)
}
var pkgs []model.Package
for _, d := range proj.Dependencies {
groupID, artifactID := strings.TrimSpace(d.GroupID), strings.TrimSpace(d.ArtifactID)
version := resolveMavenVersion(d.Version, props)
if groupID == "" || artifactID == "" || version == "" {
continue
}
pkgs = append(pkgs, model.Package{
Name: groupID + ":" + artifactID, Version: version, Ecosystem: model.EcosystemMaven, Source: path,
})
}
return pkgs, nil
}
func resolveMavenVersion(v string, props map[string]string) string {
v = strings.TrimSpace(v)
if !strings.HasPrefix(v, "${") || !strings.HasSuffix(v, "}") {
return v
}
key := strings.TrimSuffix(strings.TrimPrefix(v, "${"), "}")
return props[key] // "" if unresolved (e.g. a parent-POM or built-in property), caller skips
}
package manifest
import (
"encoding/json"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type npmLockParser struct{}
func (npmLockParser) Match(name string) bool { return name == "package-lock.json" }
type npmLockFile struct {
Packages map[string]struct {
Version string `json:"version"`
} `json:"packages"` // npm lockfile v2/v3
Dependencies map[string]struct {
Version string `json:"version"`
} `json:"dependencies"` // npm lockfile v1
}
func (npmLockParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock npmLockFile
if err := json.Unmarshal(data, &lock); err != nil {
return nil, err
}
var pkgs []model.Package
if len(lock.Packages) > 0 {
for key, p := range lock.Packages {
if key == "" || p.Version == "" {
continue // root package entry has no name
}
idx := strings.LastIndex(key, "node_modules/")
if idx < 0 {
continue // local workspace member (e.g. "packages/ui"), not a resolvable npm-registry package
}
name := key[idx+len("node_modules/"):]
pkgs = append(pkgs, model.Package{
Name: name, Version: p.Version, Ecosystem: model.EcosystemNpm, Source: path,
})
}
return pkgs, nil
}
for name, d := range lock.Dependencies {
pkgs = append(pkgs, model.Package{
Name: name, Version: d.Version, Ecosystem: model.EcosystemNpm, Source: path,
})
}
return pkgs, nil
}
package manifest
import (
"encoding/json"
"os"
"github.com/colibrisec/ojo/internal/model"
)
type nugetLockParser struct{}
func (nugetLockParser) Match(name string) bool { return name == "packages.lock.json" }
type nugetLockFile struct {
Dependencies map[string]map[string]struct {
Resolved string `json:"resolved"`
} `json:"dependencies"`
}
func (nugetLockParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock nugetLockFile
if err := json.Unmarshal(data, &lock); err != nil {
return nil, err
}
seen := map[string]bool{} // dedupe the same (name, version) seen under multiple target frameworks
var pkgs []model.Package
for _, framework := range lock.Dependencies {
for name, entry := range framework {
if entry.Resolved == "" {
continue
}
key := name + "@" + entry.Resolved
if seen[key] {
continue
}
seen[key] = true
pkgs = append(pkgs, model.Package{
Name: name, Version: entry.Resolved, Ecosystem: model.EcosystemNuGet, Source: path,
})
}
}
return pkgs, nil
}
package manifest
import (
"bufio"
"os"
"regexp"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type pipRequirementsParser struct{}
func (pipRequirementsParser) Match(name string) bool { return name == "requirements.txt" }
var pipPinRe = regexp.MustCompile(`^([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)`)
func (pipRequirementsParser) Parse(path string) ([]model.Package, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var pkgs []model.Package
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
m := pipPinRe.FindStringSubmatch(line)
if m == nil {
continue
}
pkgs = append(pkgs, model.Package{
Name: m[1], Version: m[2], Ecosystem: model.EcosystemPyPI, Source: path,
})
}
return pkgs, scanner.Err()
}
package manifest
import (
"encoding/json"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type pipfileLockParser struct{}
func (pipfileLockParser) Match(name string) bool { return name == "Pipfile.lock" }
type pipfileLockFile struct {
Default map[string]pipfileEntry `json:"default"`
Develop map[string]pipfileEntry `json:"develop"`
}
type pipfileEntry struct {
Version string `json:"version"` // typically "==1.2.3"
}
func (pipfileLockParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock pipfileLockFile
if err := json.Unmarshal(data, &lock); err != nil {
return nil, err
}
pkgs := make([]model.Package, 0, len(lock.Default)+len(lock.Develop))
for name, e := range lock.Default {
if p, ok := pipfilePackage(name, e, path); ok {
pkgs = append(pkgs, p)
}
}
for name, e := range lock.Develop {
if p, ok := pipfilePackage(name, e, path); ok {
pkgs = append(pkgs, p)
}
}
return pkgs, nil
}
func pipfilePackage(name string, e pipfileEntry, path string) (model.Package, bool) {
if !strings.HasPrefix(e.Version, "==") {
return model.Package{}, false
}
version := strings.TrimPrefix(e.Version, "==")
return model.Package{Name: name, Version: version, Ecosystem: model.EcosystemPyPI, Source: path}, true
}
package manifest
import "github.com/colibrisec/ojo/internal/model"
type poetryLockParser struct{}
func (poetryLockParser) Match(name string) bool { return name == "poetry.lock" }
func (poetryLockParser) Parse(path string) ([]model.Package, error) {
return parseTomlPackages(path, model.EcosystemPyPI)
}
package manifest
import (
"os"
"gopkg.in/yaml.v3"
"github.com/colibrisec/ojo/internal/model"
)
type pubspecLockParser struct{}
func (pubspecLockParser) Match(name string) bool { return name == "pubspec.lock" }
type pubspecLockFile struct {
Packages map[string]struct {
Version string `yaml:"version"`
} `yaml:"packages"`
}
func (pubspecLockParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock pubspecLockFile
if err := yaml.Unmarshal(data, &lock); err != nil {
return nil, err
}
pkgs := make([]model.Package, 0, len(lock.Packages))
for name, p := range lock.Packages {
if p.Version == "" {
continue
}
pkgs = append(pkgs, model.Package{Name: name, Version: p.Version, Ecosystem: model.EcosystemPub, Source: path})
}
return pkgs, nil
}
package manifest
import (
"encoding/json"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type swiftPackageResolvedParser struct{}
func (swiftPackageResolvedParser) Match(name string) bool { return name == "Package.resolved" }
type swiftPinState struct {
Version string `json:"version"`
}
type swiftResolvedFile struct {
Pins []swiftPinV2 `json:"pins"` // v2/v3
Object *struct {
Pins []swiftPinV1 `json:"pins"`
} `json:"object"` // v1
}
type swiftPinV2 struct {
Location string `json:"location"`
State swiftPinState `json:"state"`
}
type swiftPinV1 struct {
RepositoryURL string `json:"repositoryURL"`
State swiftPinState `json:"state"`
}
func (swiftPackageResolvedParser) Parse(path string) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var f swiftResolvedFile
if err := json.Unmarshal(data, &f); err != nil {
return nil, err
}
var pkgs []model.Package
if f.Object != nil {
for _, p := range f.Object.Pins {
if pkg, ok := swiftPackage(p.RepositoryURL, p.State.Version, path); ok {
pkgs = append(pkgs, pkg)
}
}
return pkgs, nil
}
for _, p := range f.Pins {
if pkg, ok := swiftPackage(p.Location, p.State.Version, path); ok {
pkgs = append(pkgs, pkg)
}
}
return pkgs, nil
}
func swiftPackage(url, version, path string) (model.Package, bool) {
if url == "" || version == "" {
return model.Package{}, false
}
return model.Package{Name: normalizeSwiftURL(url), Version: version, Ecosystem: model.EcosystemSwiftURL, Source: path}, true
}
func normalizeSwiftURL(url string) string {
for _, prefix := range []string{"https://", "http://", "ssh://", "git://"} {
url = strings.TrimPrefix(url, prefix)
}
return strings.TrimSuffix(url, ".git")
}
package manifest
import (
"os"
"github.com/pelletier/go-toml/v2"
"github.com/colibrisec/ojo/internal/model"
)
type tomlPackageList struct {
Package []struct {
Name string `toml:"name"`
Version string `toml:"version"`
} `toml:"package"`
}
func parseTomlPackages(path string, eco model.Ecosystem) ([]model.Package, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var lock tomlPackageList
if err := toml.Unmarshal(data, &lock); err != nil {
return nil, err
}
pkgs := make([]model.Package, 0, len(lock.Package))
for _, p := range lock.Package {
if p.Name == "" || p.Version == "" {
continue
}
pkgs = append(pkgs, model.Package{Name: p.Name, Version: p.Version, Ecosystem: eco, Source: path})
}
return pkgs, nil
}
package misconfig
import (
"fmt"
"regexp"
)
// Shared text heuristics for content an LLM agent reads as instructions or
// tool metadata -- an MCP server's description, a skill's body. Deliberately
// narrow, concrete signals (a specific phrase list, specific codepoints),
// not an attempt at a semantic classifier. Expect a higher false-positive
// rate here than the rest of this package's structural checks; see
// docs/guide/scanner/misconfiguration.md's ceiling notes.
// injectionPhraseRe matches a small set of known prompt-injection/tool-
// poisoning phrasings: instructions telling the model to override its prior
// instructions, or to hide its actions from the user. Both are the
// recurring core of real-world MCP "tool poisoning" and skill/prompt
// injection payloads -- an attacker doesn't need many distinct phrasings to
// achieve either goal, so this list stays short and high-signal rather than
// trying to be exhaustive.
var injectionPhraseRe = regexp.MustCompile(`(?i)(ignore (all |any )?(previous|prior|the above) instructions` +
`|disregard (the |all )?(above|previous) instructions` +
`|new instructions\s*:` +
`|do not (tell|inform|mention (this|it) to) the user` +
`|without (telling|notifying|the knowledge of) the user` +
`|hidden from the user` +
`|reveal your (system prompt|system instructions)` +
`|this is (a |the )?system (prompt|message|instruction))`)
func hasInjectionLanguage(s string) bool {
return injectionPhraseRe.MatchString(s)
}
// findHiddenUnicode checks a set of codepoints with real history as an
// invisible payload carrier and no legitimate purpose in ordinary prose:
// the Unicode "tag" block (smuggles invisible instructions past a human
// reviewer while an LLM still decodes them), zero-width space and word
// joiner, the classic bidi override controls (the "Trojan Source"
// mechanism), and a mid-text ZWNBSP. Deliberately excluded: zero-width
// joiner (legitimate in emoji sequences) and the bidi *isolate* characters
// (legitimate in modern i18n text) -- both would cost real false positives
// for a marginal detection gain.
func findHiddenUnicode(s string) (rune, bool) {
for i, r := range s {
switch {
case r >= 0xE0000 && r <= 0xE007F: // Unicode tag block
return r, true
case r == 0x200B, r == 0x2060: // zero-width space, word joiner
return r, true
case r >= 0x202A && r <= 0x202E: // bidi embedding/override controls
return r, true
case r == 0xFEFF && i > 0: // ZWNBSP mid-text -- a leading BOM is normal
return r, true
}
}
return 0, false
}
func unicodeCodepoint(r rune) string {
return fmt.Sprintf("%04X", r)
}
package misconfig
import (
"archive/zip"
"bytes"
"encoding/binary"
"encoding/xml"
"fmt"
"io"
"strings"
"github.com/shogo82148/androidbinary"
"github.com/colibrisec/ojo/internal/model"
)
// maxManifestSize caps how many bytes decodeAndroidManifest will read from
// a .apk's AndroidManifest.xml zip entry. A real AndroidManifest.xml is
// single-digit KB; this is deliberately generous while still defending
// against a decompression bomb (a few KB of zip data that inflates to many
// GB) in a fully attacker-controlled binary.
const maxManifestSize = 10 * 1024 * 1024 // 10 MiB
func isAPKFile(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".apk")
}
type androidPermission struct {
Name string `xml:"http://schemas.android.com/apk/res/android name,attr"`
}
// androidComponent covers an activity/service/receiver/provider -- the four
// manifest element types that share the same exported/permission/
// intent-filter shape.
type androidComponent struct {
Name string `xml:"http://schemas.android.com/apk/res/android name,attr"`
Exported string `xml:"http://schemas.android.com/apk/res/android exported,attr"`
Permission string `xml:"http://schemas.android.com/apk/res/android permission,attr"`
IntentFilters []androidIntentFilter `xml:"intent-filter"`
}
// androidIntentFilter models only what's needed to detect the standard
// MAIN+LAUNCHER combination -- the one intent-filter shape that's expected,
// required, and present on virtually every app's entry activity, so an
// exported-with-no-permission finding on it is a false positive rather than
// a real misconfiguration signal.
type androidIntentFilter struct {
Actions []androidIntentFilterName `xml:"action"`
Categories []androidIntentFilterName `xml:"category"`
}
type androidIntentFilterName struct {
Name string `xml:"http://schemas.android.com/apk/res/android name,attr"`
}
// isLauncherIntentFilter reports whether f is the standard "this is the
// app's main entry point" intent-filter: android.intent.action.MAIN paired
// with android.intent.category.LAUNCHER in the same filter.
func isLauncherIntentFilter(f androidIntentFilter) bool {
hasMain := false
for _, a := range f.Actions {
if a.Name == "android.intent.action.MAIN" {
hasMain = true
break
}
}
if !hasMain {
return false
}
for _, c := range f.Categories {
if c.Name == "android.intent.category.LAUNCHER" {
return true
}
}
return false
}
// componentHasLauncherIntent reports whether any of c's intent-filters is
// the standard MAIN+LAUNCHER launcher-activity filter.
func componentHasLauncherIntent(c androidComponent) bool {
for _, f := range c.IntentFilters {
if isLauncherIntentFilter(f) {
return true
}
}
return false
}
type androidApplication struct {
Debuggable string `xml:"http://schemas.android.com/apk/res/android debuggable,attr"`
UsesCleartextTraffic string `xml:"http://schemas.android.com/apk/res/android usesCleartextTraffic,attr"`
Permission string `xml:"http://schemas.android.com/apk/res/android permission,attr"`
Activities []androidComponent `xml:"activity"`
Services []androidComponent `xml:"service"`
Receivers []androidComponent `xml:"receiver"`
Providers []androidComponent `xml:"provider"`
}
type androidManifest struct {
XMLName xml.Name `xml:"manifest"`
Package string `xml:"package,attr"`
UsesPermissions []androidPermission `xml:"uses-permission"`
Application androidApplication `xml:"application"`
}
// sanityCheckAXML performs a minimal size-vs-declared-count pre-flight over
// raw AXML bytes before handing them to androidbinary.NewXMLFile. AXML's
// string pool chunk declares its StringCount/StyleCount near the front of
// the file (byte offsets 16 and 20 -- 8 bytes for the outer chunk's own
// ResChunkHeader, then 8 more for the string pool chunk's own ResChunkHeader,
// then the two uint32 counts), and androidbinary trusts those values to
// allocate a same-sized slice with no bounds check -- a crafted file
// declaring an enormous count triggers an unrecoverable
// "fatal error: out of memory" (recover() cannot catch a Go runtime fatal
// error) before any real parsing happens. A pool can't legitimately declare
// more strings/styles than there's room for 4-byte offset entries in the
// file, so this catches the attack class cheaply and deterministically.
func sanityCheckAXML(raw []byte) error {
const stringCountOffset = 16
const minLen = stringCountOffset + 8 // + StringCount(4) + StyleCount(4)
if len(raw) < minLen {
return fmt.Errorf("AXML data too short (%d bytes) to contain a string pool header", len(raw))
}
stringCount := binary.LittleEndian.Uint32(raw[stringCountOffset : stringCountOffset+4])
styleCount := binary.LittleEndian.Uint32(raw[stringCountOffset+4 : stringCountOffset+8])
if uint64(stringCount)*4 > uint64(len(raw)) {
return fmt.Errorf("AXML string pool declares %d strings, impossible for a %d-byte file", stringCount, len(raw))
}
if uint64(styleCount)*4 > uint64(len(raw)) {
return fmt.Errorf("AXML string pool declares %d styles, impossible for a %d-byte file", styleCount, len(raw))
}
return nil
}
// decodeAndroidManifest opens path as a zip archive, decodes its
// AndroidManifest.xml entry from Android's compiled binary XML format
// (AXML) via androidbinary, and unmarshals the resulting plain XML into an
// androidManifest.
func decodeAndroidManifest(path string) (androidManifest, error) {
r, err := zip.OpenReader(path)
if err != nil {
return androidManifest{}, err
}
defer r.Close()
var raw []byte
for _, f := range r.File {
if f.Name == "AndroidManifest.xml" {
if f.UncompressedSize64 > maxManifestSize {
return androidManifest{}, fmt.Errorf("%s: AndroidManifest.xml entry too large (%d bytes, cap %d)", path, f.UncompressedSize64, uint64(maxManifestSize))
}
rc, err := f.Open()
if err != nil {
return androidManifest{}, err
}
raw, err = io.ReadAll(io.LimitReader(rc, maxManifestSize+1))
rc.Close()
if err != nil {
return androidManifest{}, err
}
if len(raw) > maxManifestSize {
return androidManifest{}, fmt.Errorf("%s: AndroidManifest.xml entry exceeds %d-byte cap", path, maxManifestSize)
}
break
}
}
if raw == nil {
return androidManifest{}, fmt.Errorf("%s: no AndroidManifest.xml entry", path)
}
if err := sanityCheckAXML(raw); err != nil {
return androidManifest{}, fmt.Errorf("%s: %w", path, err)
}
xf, err := androidbinary.NewXMLFile(bytes.NewReader(raw))
if err != nil {
return androidManifest{}, err
}
xmlBytes, err := io.ReadAll(xf.Reader())
if err != nil {
return androidManifest{}, err
}
var m androidManifest
if err := xml.Unmarshal(xmlBytes, &m); err != nil {
return androidManifest{}, err
}
return m, nil
}
// scanAndroidManifest decodes path's AndroidManifest.xml and runs every
// android-* misconfig check against it.
func scanAndroidManifest(path string) ([]model.Issue, error) {
m, err := decodeAndroidManifest(path)
if err != nil {
return nil, err
}
var issues []model.Issue
issues = append(issues, checkAndroidDebuggable(m, path)...)
issues = append(issues, checkAndroidCleartextTraffic(m, path)...)
issues = append(issues, checkAndroidExportedComponents(m, path)...)
issues = append(issues, checkAndroidBroadPermissions(m, path)...)
return issues, nil
}
func checkAndroidDebuggable(m androidManifest, path string) []model.Issue {
if m.Application.Debuggable == "true" {
return []model.Issue{newIssue("android-debuggable", "HIGH", path, 1,
"Application is debuggable",
`<application android:debuggable="true"> ships in the built APK`)}
}
return nil
}
func checkAndroidCleartextTraffic(m androidManifest, path string) []model.Issue {
if m.Application.UsesCleartextTraffic == "true" {
return []model.Issue{newIssue("android-cleartext-traffic", "MEDIUM", path, 1,
"Application explicitly allows cleartext network traffic",
`<application android:usesCleartextTraffic="true">`)}
}
return nil
}
func checkAndroidExportedComponents(m androidManifest, path string) []model.Issue {
var issues []model.Issue
kinds := []struct {
kind string
list []androidComponent
}{
{"activity", m.Application.Activities},
{"service", m.Application.Services},
{"receiver", m.Application.Receivers},
{"provider", m.Application.Providers},
}
for _, k := range kinds {
for _, c := range k.list {
// A component with no explicit android:exported is exported by
// default when it has an intent-filter -- real pre-API-31
// platform behavior, not a guess.
exported := c.Exported == "true" || (c.Exported == "" && len(c.IntentFilters) > 0)
if !exported {
continue
}
if c.Permission != "" || m.Application.Permission != "" {
continue
}
if componentHasLauncherIntent(c) {
continue
}
issues = append(issues, newIssue("android-exported-component-no-permission", "HIGH", path, 1,
"Exported component has no permission guard",
fmt.Sprintf("%s %s is exported with no android:permission at the component or application level", k.kind, c.Name)))
}
}
return issues
}
// androidBroadPermissions is a curated, deliberately non-exhaustive list of
// high-risk permissions -- same curated-list precedent as this package's
// mcp-cross-origin-credential vendor table.
var androidBroadPermissions = map[string]bool{
"android.permission.QUERY_ALL_PACKAGES": true,
"android.permission.SYSTEM_ALERT_WINDOW": true,
"android.permission.REQUEST_INSTALL_PACKAGES": true,
"android.permission.READ_SMS": true,
"android.permission.RECEIVE_SMS": true,
"android.permission.BIND_ACCESSIBILITY_SERVICE": true,
"android.permission.WRITE_SECURE_SETTINGS": true,
"android.permission.MANAGE_EXTERNAL_STORAGE": true,
}
func checkAndroidBroadPermissions(m androidManifest, path string) []model.Issue {
var issues []model.Issue
for _, p := range m.UsesPermissions {
if androidBroadPermissions[p.Name] {
issues = append(issues, newIssue("android-broad-permission", "MEDIUM", path, 1,
"Requests a high-risk permission",
"uses-permission "+p.Name))
}
}
return issues
}
package misconfig
import (
"bytes"
"encoding/json"
"os"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"github.com/colibrisec/ojo/internal/model"
)
// CloudFormation templates come in two shapes -- YAML with short-form
// intrinsic-function tags (!Ref, !GetAtt, !Sub, ...) or JSON with the
// equivalent long form ({"Ref": ...}, {"Fn::GetAtt": ...}, ...). Both are
// normalized into cfnNode so checks only need to be written once. Anything
// that isn't a literal (an intrinsic function call, in either shape) comes
// through as cfnNode{kind: cfnUnknown}, the same "can't tell" treatment the
// Terraform checks give an unresolvable expression.
type cfnKind int
const (
cfnUnknown cfnKind = iota
cfnString
cfnBool
cfnNumber
cfnNull
cfnList
cfnMap
)
type cfnNode struct {
kind cfnKind
str string
b bool
num float64
line int
list []cfnNode
m map[string]cfnNode
}
func (n cfnNode) get(key string) (cfnNode, bool) {
if n.kind != cfnMap {
return cfnNode{}, false
}
v, ok := n.m[key]
return v, ok
}
func (n cfnNode) getString(key string) (string, bool) {
v, ok := n.get(key)
if !ok || v.kind != cfnString {
return "", false
}
return v.str, true
}
func (n cfnNode) getBool(key string) (bool, bool) {
v, ok := n.get(key)
if !ok || v.kind != cfnBool {
return false, false
}
return v.b, true
}
// containsString reports whether n -- a literal string, or a list
// containing one -- equals target. Used for CFN properties that accept
// either a single value or a list (IAM policy Action/Resource, etc.).
func (n cfnNode) containsString(target string) bool {
switch n.kind {
case cfnString:
return n.str == target
case cfnList:
for _, item := range n.list {
if item.kind == cfnString && item.str == target {
return true
}
}
}
return false
}
var yamlKnownTags = map[string]bool{
"": true, "!!str": true, "!!bool": true, "!!int": true, "!!float": true,
"!!null": true, "!!map": true, "!!seq": true, "!!binary": true, "!!timestamp": true,
}
func cfnFromYAML(n *yaml.Node) cfnNode {
if n == nil {
return cfnNode{kind: cfnUnknown}
}
if n.Kind == yaml.DocumentNode {
if len(n.Content) == 0 {
return cfnNode{kind: cfnUnknown}
}
return cfnFromYAML(n.Content[0])
}
if n.Kind == yaml.AliasNode {
return cfnFromYAML(n.Alias)
}
if !yamlKnownTags[n.Tag] {
return cfnNode{kind: cfnUnknown, line: n.Line} // !Ref, !GetAtt, !Sub, !If, ...
}
switch n.Kind {
case yaml.ScalarNode:
switch n.Tag {
case "!!bool":
return cfnNode{kind: cfnBool, b: n.Value == "true", line: n.Line}
case "!!int", "!!float":
f, _ := strconv.ParseFloat(n.Value, 64)
return cfnNode{kind: cfnNumber, num: f, line: n.Line}
case "!!null":
return cfnNode{kind: cfnNull, line: n.Line}
default:
return cfnNode{kind: cfnString, str: n.Value, line: n.Line}
}
case yaml.SequenceNode:
list := make([]cfnNode, 0, len(n.Content))
for _, c := range n.Content {
list = append(list, cfnFromYAML(c))
}
return cfnNode{kind: cfnList, list: list, line: n.Line}
case yaml.MappingNode:
m := make(map[string]cfnNode, len(n.Content)/2)
for i := 0; i+1 < len(n.Content); i += 2 {
m[n.Content[i].Value] = cfnFromYAML(n.Content[i+1])
}
return cfnNode{kind: cfnMap, m: m, line: n.Line}
default:
return cfnNode{kind: cfnUnknown, line: n.Line}
}
}
func cfnFromJSON(v any) cfnNode {
switch t := v.(type) {
case string:
return cfnNode{kind: cfnString, str: t}
case bool:
return cfnNode{kind: cfnBool, b: t}
case float64:
return cfnNode{kind: cfnNumber, num: t}
case nil:
return cfnNode{kind: cfnNull}
case []any:
list := make([]cfnNode, 0, len(t))
for _, item := range t {
list = append(list, cfnFromJSON(item))
}
return cfnNode{kind: cfnList, list: list}
case map[string]any:
// A single-key object like {"Ref": "X"} or {"Fn::GetAtt": [...]} is
// CFN's JSON intrinsic-function form -- indistinguishable from a
// literal nested object without knowing every intrinsic name, so
// treat any Ref/Fn::*/Condition key the same as an unresolvable
// YAML tag.
if len(t) == 1 {
for k := range t {
if k == "Ref" || k == "Condition" || strings.HasPrefix(k, "Fn::") {
return cfnNode{kind: cfnUnknown}
}
}
}
m := make(map[string]cfnNode, len(t))
for k, val := range t {
m[k] = cfnFromJSON(val)
}
return cfnNode{kind: cfnMap, m: m}
default:
return cfnNode{kind: cfnUnknown}
}
}
// looksLikeCloudFormation guards against false-triggering on arbitrary
// YAML/JSON that happens to have a top-level "Resources" key.
func looksLikeCloudFormation(resources cfnNode) bool {
for _, r := range resources.m {
if t, ok := r.getString("Type"); ok {
if strings.HasPrefix(t, "AWS::") || strings.HasPrefix(t, "Alexa::") || strings.HasPrefix(t, "Custom::") {
return true
}
}
}
return false
}
func scanCloudFormation(path string) ([]model.Issue, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var root cfnNode
if trimmed := bytes.TrimSpace(data); len(trimmed) > 0 && trimmed[0] == '{' {
var raw any
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil // ponytail: not valid JSON, skip rather than fail the scan
}
root = cfnFromJSON(raw)
} else {
var node yaml.Node
if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 {
return nil, nil
}
root = cfnFromYAML(&node)
}
resources, ok := root.get("Resources")
if !ok || resources.kind != cfnMap || !looksLikeCloudFormation(resources) {
return nil, nil
}
var issues []model.Issue
for name, res := range resources.m {
resType, ok := res.getString("Type")
if !ok {
continue
}
props, _ := res.get("Properties")
line := res.line
if line == 0 {
line = 1
}
for _, check := range cloudformationChecks {
issues = append(issues, check(resType, name, path, line, props)...)
}
}
return issues, nil
}
type cfnCheck func(resType, resName, path string, line int, props cfnNode) []model.Issue
var cloudformationChecks = []cfnCheck{
checkCFNS3Bucket,
checkCFNSecurityGroup,
checkCFNRDSInstance,
checkCFNIAMPolicy,
checkCFNIMDSv2,
checkCFNLoadBalancerScheme,
checkCFNKMSRotation,
checkCFNCloudTrail,
checkCFNDynamoDBPITR,
checkCFNECR,
}
func checkCFNS3Bucket(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::S3::Bucket" {
return nil
}
var issues []model.Issue
if ac, ok := props.getString("AccessControl"); ok {
switch ac {
case "PublicRead", "PublicReadWrite", "AuthenticatedRead":
issues = append(issues, newIssue("cfn-s3-public-acl", "CRITICAL", path, line,
"S3 bucket has a public ACL", resName+" AccessControl = "+ac))
}
}
if _, ok := props.get("BucketEncryption"); !ok {
issues = append(issues, newIssue("cfn-s3-unencrypted", "MEDIUM", path, line,
"S3 bucket has no BucketEncryption configured", resName))
}
versioned := false
if vc, ok := props.get("VersioningConfiguration"); ok {
if status, ok := vc.getString("Status"); ok && status == "Enabled" {
versioned = true
}
}
if !versioned {
issues = append(issues, newIssue("cfn-s3-versioning-disabled", "MEDIUM", path, line,
"S3 bucket does not have versioning enabled", resName))
}
if pab, ok := props.get("PublicAccessBlockConfiguration"); !ok {
issues = append(issues, newIssue("cfn-s3-missing-public-access-block", "HIGH", path, line,
"S3 bucket has no PublicAccessBlockConfiguration", resName))
} else {
complete := true
for _, key := range []string{"BlockPublicAcls", "BlockPublicPolicy", "IgnorePublicAcls", "RestrictPublicBuckets"} {
if v, ok := pab.getBool(key); !ok || !v {
complete = false
}
}
if !complete {
issues = append(issues, newIssue("cfn-s3-public-access-block-incomplete", "HIGH", path, line,
"S3 bucket's PublicAccessBlockConfiguration does not block all public access", resName))
}
}
return issues
}
func checkCFNSecurityGroup(resType, resName, path string, line int, props cfnNode) []model.Issue {
var issues []model.Issue
switch resType {
case "AWS::EC2::SecurityGroup":
if ing, ok := props.get("SecurityGroupIngress"); ok {
issues = append(issues, cfnSGRuleIssues(ing, "ingress", resName, path, line)...)
}
if eg, ok := props.get("SecurityGroupEgress"); ok {
issues = append(issues, cfnSGRuleIssues(eg, "egress", resName, path, line)...)
}
case "AWS::EC2::SecurityGroupIngress":
issues = append(issues, cfnSGRuleIssues(cfnNode{kind: cfnList, list: []cfnNode{props}}, "ingress", resName, path, line)...)
case "AWS::EC2::SecurityGroupEgress":
issues = append(issues, cfnSGRuleIssues(cfnNode{kind: cfnList, list: []cfnNode{props}}, "egress", resName, path, line)...)
}
return issues
}
func cfnSGRuleIssues(rules cfnNode, kind, resName, path string, line int) []model.Issue {
if rules.kind != cfnList {
return nil
}
ruleID := "cfn-security-group-open-ingress"
title := "Security group allows ingress from 0.0.0.0/0"
if kind == "egress" {
ruleID = "cfn-security-group-open-egress"
title = "Security group allows unrestricted egress to 0.0.0.0/0"
}
for _, rule := range rules.list {
if cidr, ok := rule.getString("CidrIp"); ok && cidr == "0.0.0.0/0" {
return []model.Issue{newIssue(ruleID, "CRITICAL", path, line, title, resName)}
}
if cidr, ok := rule.getString("CidrIpv6"); ok && cidr == "::/0" {
return []model.Issue{newIssue(ruleID, "CRITICAL", path, line, title, resName)}
}
}
return nil
}
func checkCFNRDSInstance(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::RDS::DBInstance" {
return nil
}
var issues []model.Issue
if v, ok := props.getBool("StorageEncrypted"); !ok || !v {
issues = append(issues, newIssue("cfn-rds-unencrypted", "HIGH", path, line,
"RDS instance is not encrypted", resName))
}
if v, ok := props.getBool("PubliclyAccessible"); ok && v {
issues = append(issues, newIssue("cfn-rds-publicly-accessible", "HIGH", path, line,
"RDS instance is publicly accessible", resName))
}
return issues
}
func checkCFNIAMPolicy(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::IAM::Policy" && resType != "AWS::IAM::ManagedPolicy" {
return nil
}
doc, ok := props.get("PolicyDocument")
if !ok {
return nil
}
stmts, ok := doc.get("Statement")
if !ok {
return nil
}
if stmts.kind == cfnMap {
stmts = cfnNode{kind: cfnList, list: []cfnNode{stmts}}
}
if stmts.kind != cfnList {
return nil
}
for _, s := range stmts.list {
effect, _ := s.getString("Effect")
action, hasAction := s.get("Action")
resource, hasResource := s.get("Resource")
if effect == "Allow" && hasAction && hasResource && action.containsString("*") && resource.containsString("*") {
return []model.Issue{newIssue("cfn-iam-wildcard-policy", "CRITICAL", path, line,
"IAM policy grants Action=* on Resource=*", resName)}
}
}
return nil
}
func checkCFNIMDSv2(resType, resName, path string, line int, props cfnNode) []model.Issue {
var mo cfnNode
var ok bool
switch resType {
case "AWS::EC2::Instance":
mo, ok = props.get("MetadataOptions")
case "AWS::EC2::LaunchTemplate":
if data, dataOk := props.get("LaunchTemplateData"); dataOk {
mo, ok = data.get("MetadataOptions")
}
default:
return nil
}
if !ok {
return []model.Issue{newIssue("cfn-imdsv1-enabled", "HIGH", path, line,
"Instance metadata service allows IMDSv1 (no MetadataOptions)", resName)}
}
if tokens, ok := mo.getString("HttpTokens"); !ok || tokens != "required" {
return []model.Issue{newIssue("cfn-imdsv1-enabled", "HIGH", path, line,
"Instance metadata service allows IMDSv1 (HttpTokens != \"required\")", resName)}
}
return nil
}
func checkCFNLoadBalancerScheme(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::ElasticLoadBalancingV2::LoadBalancer" {
return nil
}
if scheme, ok := props.getString("Scheme"); !ok || scheme != "internal" {
return []model.Issue{newIssue("cfn-lb-internet-facing", "HIGH", path, line,
"Load balancer is internet-facing", resName)}
}
return nil
}
func checkCFNKMSRotation(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::KMS::Key" {
return nil
}
if v, ok := props.getBool("EnableKeyRotation"); !ok || !v {
return []model.Issue{newIssue("cfn-kms-rotation-disabled", "MEDIUM", path, line,
"KMS key does not have automatic rotation enabled", resName)}
}
return nil
}
func checkCFNCloudTrail(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::CloudTrail::Trail" {
return nil
}
var issues []model.Issue
if v, ok := props.getBool("EnableLogFileValidation"); !ok || !v {
issues = append(issues, newIssue("cfn-cloudtrail-no-log-validation", "MEDIUM", path, line,
"CloudTrail trail does not have log file validation enabled", resName))
}
if v, ok := props.getBool("IsMultiRegionTrail"); !ok || !v {
issues = append(issues, newIssue("cfn-cloudtrail-not-multi-region", "LOW", path, line,
"CloudTrail trail is not multi-region", resName))
}
return issues
}
func checkCFNDynamoDBPITR(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::DynamoDB::Table" {
return nil
}
if spec, ok := props.get("PointInTimeRecoverySpecification"); ok {
if v, ok := spec.getBool("PointInTimeRecoveryEnabled"); ok && v {
return nil
}
}
return []model.Issue{newIssue("cfn-dynamodb-pitr-disabled", "MEDIUM", path, line,
"DynamoDB table does not have point-in-time recovery enabled", resName)}
}
func checkCFNECR(resType, resName, path string, line int, props cfnNode) []model.Issue {
if resType != "AWS::ECR::Repository" {
return nil
}
if v, ok := props.getString("ImageTagMutability"); !ok || v != "IMMUTABLE" {
return []model.Issue{newIssue("cfn-ecr-tag-mutable", "HIGH", path, line,
"ECR repository allows mutable image tags", resName)}
}
return nil
}
package misconfig
import (
"bufio"
"os"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
type Instruction struct {
Cmd string // upper-cased, e.g. "FROM"
Args string
Line int
}
func isDockerfile(name string) bool {
return name == "Dockerfile" || strings.HasPrefix(name, "Dockerfile.")
}
func parseDockerfile(data []byte) []Instruction {
var out []Instruction
scanner := bufio.NewScanner(strings.NewReader(string(data)))
lineNum := 0
var pending strings.Builder
pendingStart := 0
flush := func() {
line := strings.TrimSpace(pending.String())
pending.Reset()
if line == "" || strings.HasPrefix(line, "#") {
return
}
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
args := ""
if len(parts) == 2 {
args = strings.TrimSpace(parts[1])
}
out = append(out, Instruction{Cmd: cmd, Args: args, Line: pendingStart})
}
for scanner.Scan() {
lineNum++
raw := scanner.Text()
trimmed := strings.TrimRight(raw, " \t\r")
if pending.Len() == 0 {
pendingStart = lineNum
}
if strings.HasSuffix(trimmed, "\\") {
pending.WriteString(strings.TrimSuffix(trimmed, "\\"))
pending.WriteString(" ")
continue
}
pending.WriteString(trimmed)
flush()
}
if pending.Len() > 0 {
flush()
}
return out
}
func dockerfileChecks(instrs []Instruction, path string) []model.Issue {
var issues []model.Issue
lastUser := ""
lastFrom := ""
hasHealthcheck := false
for _, in := range instrs {
switch in.Cmd {
case "FROM":
lastFrom = in.Args
// New stage: USER/HEALTHCHECK reset to Docker defaults for that stage.
lastUser = ""
hasHealthcheck = false
if !strings.Contains(lastFrom, "@sha256:") && (!strings.Contains(lastFrom, ":") || strings.HasSuffix(lastFrom, ":latest")) {
issues = append(issues, newIssue("dockerfile-latest-tag", "HIGH", path, in.Line,
"FROM image has no pinned tag (or uses :latest): "+lastFrom,
"Image "+lastFrom+" is not pinned to a specific, immutable tag or digest"))
}
case "USER":
lastUser = in.Args
case "HEALTHCHECK":
if strings.EqualFold(strings.TrimSpace(in.Args), "NONE") {
hasHealthcheck = false
} else {
hasHealthcheck = true
}
case "ENV", "ARG":
nameUpper := strings.ToUpper(in.Args)
for _, kw := range []string{"PASSWORD", "SECRET", "API_KEY", "APIKEY", "TOKEN"} {
if strings.Contains(nameUpper, kw) {
issues = append(issues, newIssue("dockerfile-secret-env", "MEDIUM", path, in.Line,
"Potential secret baked into image via "+in.Cmd,
in.Cmd+" "+in.Args))
break
}
}
case "ADD":
if !strings.Contains(in.Args, "http://") && !strings.Contains(in.Args, "https://") {
issues = append(issues, newIssue("dockerfile-add-instead-of-copy", "LOW", path, in.Line,
"ADD used for local files; prefer COPY (ADD has surprising auto-extract/remote-fetch semantics)",
"ADD "+in.Args))
}
}
}
if lastUser == "" || lastUser == "root" || lastUser == "0" {
issues = append(issues, newIssue("dockerfile-root-user", "HIGH", path, 1,
"Container runs as root (no non-root USER instruction)",
"final effective user: "+orDefault(lastUser, "root (default)")))
}
if !hasHealthcheck {
issues = append(issues, newIssue("dockerfile-no-healthcheck", "LOW", path, 1,
"No HEALTHCHECK instruction defined", "image has no HEALTHCHECK"))
}
return issues
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
func scanDockerfile(path string) ([]model.Issue, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return dockerfileChecks(parseDockerfile(data), path), nil
}
package misconfig
import (
"bytes"
"os"
"gopkg.in/yaml.v3"
"github.com/colibrisec/ojo/internal/model"
)
func scanK8sManifest(path string) ([]model.Issue, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var issues []model.Issue
dec := yaml.NewDecoder(bytes.NewReader(data))
for {
var doc map[string]any
if err := dec.Decode(&doc); err != nil {
break // EOF or a non-YAML/malformed doc; stop rather than fail the whole scan
}
if doc["apiVersion"] == nil || doc["kind"] == nil {
continue
}
issues = append(issues, k8sChecks(doc, path)...)
}
return issues, nil
}
func k8sChecks(doc map[string]any, path string) []model.Issue {
var issues []model.Issue
for _, hostField := range []struct{ key, rule string }{
{"hostNetwork", "k8s-host-network"},
{"hostPID", "k8s-host-pid"},
{"hostIPC", "k8s-host-ipc"},
} {
if b, ok := findBool(doc, hostField.key); ok && b {
issues = append(issues, newIssue(hostField.rule, "HIGH", path, 1,
hostField.key+" is enabled", hostField.key+": true grants the pod access to the host namespace"))
}
}
for _, c := range findContainers(doc) {
name, _ := c["name"].(string)
sc, _ := c["securityContext"].(map[string]any)
if sc != nil {
if v, ok := sc["privileged"].(bool); ok && v {
issues = append(issues, newIssue("k8s-privileged-container", "CRITICAL", path, 1,
"Container runs privileged", "container "+name+" has securityContext.privileged: true"))
}
if v, ok := sc["allowPrivilegeEscalation"].(bool); !ok || v {
issues = append(issues, newIssue("k8s-allow-privilege-escalation", "MEDIUM", path, 1,
"allowPrivilegeEscalation not explicitly disabled", "container "+name))
}
if v, ok := sc["runAsNonRoot"].(bool); !ok || !v {
issues = append(issues, newIssue("k8s-run-as-root", "MEDIUM", path, 1,
"runAsNonRoot not explicitly set to true", "container "+name))
}
} else {
issues = append(issues, newIssue("k8s-run-as-root", "MEDIUM", path, 1,
"No securityContext set (runs as root by default)", "container "+name))
}
resources, _ := c["resources"].(map[string]any)
if resources == nil || resources["limits"] == nil {
issues = append(issues, newIssue("k8s-missing-resource-limits", "LOW", path, 1,
"Container has no resource limits", "container "+name+" can consume unbounded CPU/memory"))
}
}
return issues
}
func findBool(v any, key string) (bool, bool) {
m, ok := v.(map[string]any)
if !ok {
return false, false
}
if raw, ok := m[key]; ok {
if b, ok := raw.(bool); ok {
return b, true
}
}
for _, val := range m {
if b, ok := findBool(val, key); ok {
return b, true
}
}
return false, false
}
// findContainers collects every element of any "containers"/"initContainers" list anywhere in doc.
func findContainers(v any) []map[string]any {
var out []map[string]any
switch t := v.(type) {
case map[string]any:
for k, val := range t {
if k == "containers" || k == "initContainers" {
if list, ok := val.([]any); ok {
for _, item := range list {
if c, ok := item.(map[string]any); ok {
out = append(out, c)
}
}
continue
}
}
out = append(out, findContainers(val)...)
}
case []any:
for _, item := range t {
out = append(out, findContainers(item)...)
}
}
return out
}
package misconfig
import (
"encoding/json"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
// MCP server configs (Claude Desktop/Code, Cursor, and Windsurf's
// "mcpServers" convention; VS Code's "servers" convention) declare how an
// MCP server process gets launched or connected to. Same shape every time
// regardless of which tool wrote the file, so one parser covers them all.
// Description/AutoApprove/AlwaysAllow aren't part of every client's schema --
// zero value when absent is a correct "nothing to check" for each of them.
type mcpServer struct {
Command string `json:"command"`
Args []string `json:"args"`
Env map[string]string `json:"env"`
URL string `json:"url"`
Description string `json:"description"`
AutoApprove []string `json:"autoApprove"`
AlwaysAllow []string `json:"alwaysAllow"`
}
// looksLikeMCPConfig guards a generic .json file against false-triggering,
// the same role looksLikeCloudFormation plays for CFN templates. "mcpServers"
// is the dominant key name; VS Code's "servers" is common enough on its own
// (e.g. a plain HTTP server list) that it's only trusted when the file's own
// basename also says "mcp" -- deliberately not the full path, since an
// ancestor directory (a repo checkout, a temp dir) saying "mcp" for an
// unrelated reason shouldn't widen the match.
func looksLikeMCPConfig(raw map[string]json.RawMessage, path string) map[string]mcpServer {
key := "mcpServers"
if _, ok := raw[key]; !ok {
key = "servers"
if _, ok := raw[key]; !ok || !strings.Contains(strings.ToLower(filepath.Base(path)), "mcp") {
return nil
}
}
var servers map[string]mcpServer
if json.Unmarshal(raw[key], &servers) != nil {
return nil
}
return servers
}
func scanMCPConfig(path string) ([]model.Issue, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return nil, nil // ponytail: not valid JSON, skip rather than fail the scan
}
servers := looksLikeMCPConfig(raw, path)
var issues []model.Issue
for name, srv := range servers {
issues = append(issues, mcpServerChecks(name, srv, path)...)
}
return issues, nil
}
func mcpServerChecks(name string, srv mcpServer, path string) []model.Issue {
var issues []model.Issue
if spec, ok := mcpUnpinnedLauncher(srv.Command, srv.Args); ok {
issues = append(issues, newIssue("mcp-unpinned-launcher", "MEDIUM", path, 1,
"MCP server launches an unpinned remote package",
name+": "+srv.Command+" ... "+spec+" (no version pin -- a compromised/typosquatted release is fetched silently on every launch)"))
}
if mcpUsesShellWrapper(srv.Command) {
issues = append(issues, newIssue("mcp-shell-wrapper", "MEDIUM", path, 1,
"MCP server is launched through a shell wrapper instead of directly",
name+": "+srv.Command+" "+strings.Join(srv.Args, " ")))
}
if srv.URL != "" && strings.HasPrefix(srv.URL, "http://") && !mcpIsLocalhost(srv.URL) {
issues = append(issues, newIssue("mcp-plaintext-transport", "HIGH", path, 1,
"MCP server URL uses plaintext HTTP instead of HTTPS",
name+": "+srv.URL))
}
if srv.Description != "" && hasInjectionLanguage(srv.Description) {
issues = append(issues, newIssue("mcp-prompt-injection-language", "MEDIUM", path, 1,
"MCP server description contains known prompt-injection phrasing (possible tool poisoning)",
name+": "+srv.Description))
}
if r, ok := findHiddenUnicode(srv.Description + " " + srv.Command + " " + strings.Join(srv.Args, " ") + " " + srv.URL); ok {
issues = append(issues, newIssue("mcp-hidden-unicode", "HIGH", path, 1,
"MCP server config contains a hidden/invisible Unicode character",
name+": U+"+unicodeCodepoint(r)))
}
if mcpAutoApprovesAll(srv) {
issues = append(issues, newIssue("mcp-auto-approve-wildcard", "MEDIUM", path, 1,
"MCP server auto-approves every tool call with no user confirmation",
name+": autoApprove/alwaysAllow includes \"*\""))
}
if srv.URL != "" {
issues = append(issues, newIssue("mcp-remote-server-unpinned", "LOW", path, 1,
"MCP server is a remote endpoint whose behavior this config can't pin",
name+": "+srv.URL+" (a remote server can change what it does at any time without a local config change -- review periodically, the classic MCP \"rug pull\" risk)"))
}
if envName, ok := mcpCrossOriginCredential(srv); ok {
issues = append(issues, newIssue("mcp-cross-origin-credential", "MEDIUM", path, 1,
"MCP server holds a credential for a vendor its own launch source doesn't reference",
name+": "+envName+" doesn't obviously match "+orDefault(srv.Command, srv.URL)))
}
return issues
}
// A wildcard in autoApprove/alwaysAllow means every tool call, present or
// added later, runs without the user ever seeing it.
func mcpAutoApprovesAll(srv mcpServer) bool {
for _, t := range append(append([]string{}, srv.AutoApprove...), srv.AlwaysAllow...) {
if strings.TrimSpace(t) == "*" {
return true
}
}
return false
}
// mcpVendorKeywords maps a short vendor keyword (matched against an env var
// name) to substrings expected somewhere in that vendor's own package name
// or hostname. A small, curated table of well-known providers -- not a
// general vendor-detection system, and a legitimate multi-service wrapper
// server will trip this; treat it as "worth a look," not "confirmed."
var mcpVendorKeywords = map[string][]string{
"github": {"github"},
"gitlab": {"gitlab"},
"aws": {"aws", "amazon"},
"gcp": {"google", "gcp"},
"azure": {"azure", "microsoft"},
"slack": {"slack"},
"stripe": {"stripe"},
"openai": {"openai"},
"anthropic": {"anthropic", "claude"},
"docker": {"docker"},
}
func mcpCrossOriginCredential(srv mcpServer) (string, bool) {
origin := strings.ToLower(srv.Command + " " + strings.Join(srv.Args, " ") + " " + srv.URL)
for envName := range srv.Env {
lower := strings.ToLower(envName)
for vendor, hints := range mcpVendorKeywords {
if !strings.Contains(lower, vendor) {
continue
}
related := false
for _, h := range hints {
if strings.Contains(origin, h) {
related = true
break
}
}
if !related {
return envName, true
}
}
}
return "", false
}
// mcpRemoteLaunchers run-and-fetch a package by name on every invocation,
// unlike a locally installed binary -- the launcher itself is trusted, but
// what it fetches is only as trustworthy as the version pin on the package.
var mcpRemoteLaunchers = map[string]bool{
"npx": true, "npx.cmd": true, "bunx": true, "uvx": true, "pipx": true,
}
func mcpUnpinnedLauncher(command string, args []string) (string, bool) {
if !mcpRemoteLaunchers[strings.ToLower(filepath.Base(command))] {
return "", false
}
for _, a := range args {
if a == "" || strings.HasPrefix(a, "-") {
continue // flag, not the package spec
}
if mcpIsPinned(a) {
return "", false
}
return a, true
}
return "", false
}
// mcpIsPinned reports whether a package spec carries an explicit version:
// pip/uvx style ("pkg==1.2.3") or npm style ("pkg@1.2.3" / "@scope/pkg@1.2.3"
// -- a scoped package's leading "@" doesn't count, only one appearing after
// it does).
func mcpIsPinned(spec string) bool {
if strings.Contains(spec, "==") {
return true
}
rest := strings.TrimPrefix(spec, "@")
return strings.Contains(rest, "@")
}
var mcpShellCommands = map[string]bool{
"sh": true, "bash": true, "zsh": true, "dash": true,
"cmd": true, "cmd.exe": true, "powershell": true, "powershell.exe": true, "pwsh": true,
}
// A shell wrapper's real command lives inside an opaque "-c"/"/c" string
// argument instead of being visible as command+args.
func mcpUsesShellWrapper(command string) bool {
return mcpShellCommands[strings.ToLower(filepath.Base(command))]
}
func mcpIsLocalhost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
switch u.Hostname() {
case "localhost", "127.0.0.1", "::1":
return true
default:
return false
}
}
// Package misconfig checks Dockerfiles, Kubernetes manifests, Terraform, MCP
// server configs, and Claude Code skill definitions for common security
// misconfigurations.
package misconfig
import (
"io/fs"
"path/filepath"
"sort"
"strings"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
func newIssue(ruleID, severity, path string, line int, title, message string) model.Issue {
return model.Issue{
Scanner: "misconfig",
RuleID: ruleID,
Title: title,
Severity: severity,
File: path,
Line: line,
Message: message,
CWEs: ruleCWEs[ruleID],
}
}
func Scan(root string) ([]model.Issue, error) {
var issues []model.Issue
tfFilesByDir := map[string][]string{}
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
name := d.Name()
switch {
case isDockerfile(name):
found, err := scanDockerfile(path)
if err != nil {
return nil // ponytail: skip unparsable file, don't fail the whole scan
}
issues = append(issues, found...)
case isSkillFile(name):
found, err := scanSkill(path)
if err != nil {
return nil
}
issues = append(issues, found...)
case isAPKFile(name):
found, err := scanAndroidManifest(path)
if err != nil {
return nil // skip unparsable/non-APK-shaped .apk file, don't fail the whole scan
}
issues = append(issues, found...)
case strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml"):
// A YAML file is tried as both a Kubernetes manifest and a
// CloudFormation template -- each is a no-op on a file that
// doesn't look like its format, so there's no real ambiguity cost.
if found, err := scanK8sManifest(path); err == nil {
issues = append(issues, found...)
}
if found, err := scanCloudFormation(path); err == nil {
issues = append(issues, found...)
}
case strings.HasSuffix(name, ".json") || strings.HasSuffix(name, ".template"):
if found, err := scanCloudFormation(path); err == nil {
issues = append(issues, found...)
}
if found, err := scanMCPConfig(path); err == nil {
issues = append(issues, found...)
}
case strings.HasSuffix(name, ".tf"):
// Grouped by directory, not scanned file-by-file: all .tf files in
// one directory form a single Terraform module, sharing locals and
// variables and routinely splitting one resource's related config
// (e.g. an S3 bucket and its versioning) across files.
dir := filepath.Dir(path)
tfFilesByDir[dir] = append(tfFilesByDir[dir], path)
}
return nil
})
if err != nil {
return issues, err
}
dirs := make([]string, 0, len(tfFilesByDir))
for dir := range tfFilesByDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
for _, dir := range dirs {
issues = append(issues, scanTerraformDir(dir, tfFilesByDir)...)
}
return issues, nil
}
package misconfig
import (
"os"
"regexp"
"strings"
"gopkg.in/yaml.v3"
"github.com/colibrisec/ojo/internal/model"
)
func isSkillFile(name string) bool {
return strings.EqualFold(name, "SKILL.md")
}
// fetchExecuteRe matches a remote-fetch command whose output is piped
// straight into a shell/interpreter -- the classic curl-pipe-bash
// supply-chain pattern, just as risky inside a skill's instructions or
// bundled script as it is in a Dockerfile RUN line.
var fetchExecuteRe = regexp.MustCompile(`(?i)\b(curl|wget|iwr|invoke-webrequest)\b[^|\n]*\|\s*(sudo\s+)?(sh|bash|zsh|python[0-9.]*|iex|invoke-expression)\b`)
// credentialPaths are well-known credential file locations -- a concrete,
// enumerable signal, the same kind internal/secret's rules key on, not a
// generic notion of "sensitive data" that would need semantic judgment.
var credentialPaths = []string{
".ssh/id_rsa", ".ssh/id_ed25519", ".aws/credentials", ".netrc",
".npmrc", ".git-credentials", ".docker/config.json", ".env",
}
// exfilVerbRe is deliberately narrow -- a handful of concrete transfer
// verbs, not an attempt at general "sounds like exfiltration" semantics.
var exfilVerbRe = regexp.MustCompile(`(?i)\b(curl|wget|post|upload|send)\b`)
// dangerousUnscopedTools are grants with no legitimate scoped form to
// distinguish from -- a bare wildcard or an unrestricted shell-exec tool.
// "Bash(git:*)" is a normal, scoped grant and doesn't match any of these.
var dangerousUnscopedTools = map[string]bool{
"*": true, "bash": true, "bash(*)": true, "shell": true, "execute": true,
}
func skillChecks(body, path string) []model.Issue {
var issues []model.Issue
if tool, ok := skillBroadToolPermission(skillFrontmatter(body)); ok {
issues = append(issues, newIssue("skill-broad-tool-permissions", "MEDIUM", path, 1,
"Skill grants itself broad/unscoped tool permissions in its frontmatter",
"allowed-tools: "+tool))
}
for i, line := range strings.Split(body, "\n") {
lineNo := i + 1
if fetchExecuteRe.MatchString(line) {
issues = append(issues, newIssue("skill-fetch-execute", "HIGH", path, lineNo,
"Skill fetches a remote script and pipes it directly into a shell",
strings.TrimSpace(line)))
}
lower := strings.ToLower(line)
for _, cp := range credentialPaths {
if strings.Contains(lower, cp) && exfilVerbRe.MatchString(line) {
issues = append(issues, newIssue("skill-credential-exfil-reference", "HIGH", path, lineNo,
"Skill references a credential file alongside an outbound-transfer command",
strings.TrimSpace(line)))
break
}
}
if hasInjectionLanguage(line) {
issues = append(issues, newIssue("skill-prompt-injection-language", "MEDIUM", path, lineNo,
"Skill contains known prompt-injection phrasing",
strings.TrimSpace(line)))
}
if r, ok := findHiddenUnicode(line); ok {
issues = append(issues, newIssue("skill-hidden-unicode", "HIGH", path, lineNo,
"Skill contains a hidden/invisible Unicode character",
"U+"+unicodeCodepoint(r)))
}
}
return issues
}
// skillFrontmatter parses a SKILL.md's leading "---"-delimited YAML block.
// Frontmatter is optional; both a missing block and a parse failure return
// nil, a normal "nothing to check" rather than an error.
func skillFrontmatter(body string) map[string]any {
rest := body
switch {
case strings.HasPrefix(rest, "---\r\n"):
rest = rest[len("---\r\n"):]
case strings.HasPrefix(rest, "---\n"):
rest = rest[len("---\n"):]
default:
return nil
}
end := strings.Index(rest, "\n---")
if end < 0 {
return nil
}
var fm map[string]any
if yaml.Unmarshal([]byte(rest[:end]), &fm) != nil {
return nil
}
return fm
}
func skillBroadToolPermission(fm map[string]any) (string, bool) {
switch t := fm["allowed-tools"].(type) {
case string:
if dangerousUnscopedTools[strings.ToLower(strings.TrimSpace(t))] {
return t, true
}
case []any:
for _, item := range t {
if s, ok := item.(string); ok && dangerousUnscopedTools[strings.ToLower(strings.TrimSpace(s))] {
return s, true
}
}
}
return "", false
}
func scanSkill(path string) ([]model.Issue, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return skillChecks(string(data), path), nil
}
package misconfig
import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/colibrisec/ojo/internal/model"
)
// Checks below intentionally treat "attribute absent" the same as "attribute
// false" for attributes whose Terraform/provider default is the insecure
// value (e.g. a security group's absent `internal` defaults to
// internet-facing). Attribute values are resolved through an hcl.EvalContext
// built from locals{} and variable{default=...} blocks in the same
// directory (see buildEvalContext) -- an expression that still can't be
// resolved (a module input, a reference to another resource's attribute,
// dynamic blocks) falls back to the same "can't tell -> flag" treatment.
// See the "literal values only" limitation in docs/roadmap.md.
// tfBlock pairs a parsed block with the file it came from, since checks
// need the file path for reporting but directory-level scanning parses
// several files together. ctx/aliases are only set on blocks pulled into
// another directory's cross-resource check pool via a local module
// reference (see scanTerraformDir) -- ctx is the block's own directory's
// eval context (its locals/variables, not the parent's), and aliases maps
// its module call's input variable names to the parent resource they were
// passed, so attrRefName can resolve a child's `var.x`-style reference.
type tfBlock struct {
block *hclsyntax.Block
path string
ctx *hcl.EvalContext
aliases map[string]resourceRef
}
// resourceRef is a resolved "resType.resName" resource address.
type resourceRef struct {
Type, Name string
}
func parseTFFile(path string) (*hclsyntax.Body, bool) {
data, err := os.ReadFile(path)
if err != nil {
return nil, false
}
f, diags := hclsyntax.ParseConfig(data, path, hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() || f == nil {
return nil, false // ponytail: skip files we can't parse, don't fail the whole scan
}
body, ok := f.Body.(*hclsyntax.Body)
return body, ok
}
// tfDirBlocks is every relevant block type parsed out of one directory's
// .tf files.
type tfDirBlocks struct {
resources, dataSources, localsBlocks, variableBlocks, moduleBlocks []tfBlock
}
func parseTFDir(files []string) tfDirBlocks {
var d tfDirBlocks
for _, path := range files {
body, ok := parseTFFile(path)
if !ok {
continue
}
for _, block := range body.Blocks {
tb := tfBlock{block: block, path: path}
switch block.Type {
case "resource":
if len(block.Labels) >= 2 {
d.resources = append(d.resources, tb)
}
case "data":
if len(block.Labels) >= 2 {
d.dataSources = append(d.dataSources, tb)
}
case "locals":
d.localsBlocks = append(d.localsBlocks, tb)
case "variable":
if len(block.Labels) >= 1 {
d.variableBlocks = append(d.variableBlocks, tb)
}
case "module":
if len(block.Labels) >= 1 {
d.moduleBlocks = append(d.moduleBlocks, tb)
}
}
}
}
return d
}
// terraformAttachmentResourceTypes are the resource types "attached" to
// another resource declared elsewhere (an S3 bucket's
// versioning/encryption/logging/public-access-block, a VPC's flow log) --
// the only types scanTerraformDir pulls in from a locally-sourced module
// subdirectory, since those are what real Terraform module layouts most
// often split out (see scanTerraformDir).
var terraformAttachmentResourceTypes = map[string]bool{
"aws_s3_bucket_versioning": true,
"aws_s3_bucket_server_side_encryption_configuration": true,
"aws_s3_bucket_logging": true,
"aws_s3_bucket_public_access_block": true,
"aws_flow_log": true,
}
// resourceRefFromExpr reports the "resType.resName" a bare HCL traversal
// expression resolves to, e.g. `aws_s3_bucket.data.id` -> ("aws_s3_bucket",
// "data"). Used both for the existing same-directory
// `bucket = aws_s3_bucket.x.id` case and (via resolveLocalModule) for a
// module call argument's expression -- the root name isn't validated to
// actually be a resource type here (it might be "var"/"data"/etc.), that's
// left to the caller (see attrRefName).
func resourceRefFromExpr(expr hcl.Expression) (resourceRef, bool) {
for _, t := range expr.Variables() {
if len(t) < 2 {
continue
}
root, ok := t[0].(hcl.TraverseRoot)
if !ok {
continue
}
attr, ok := t[1].(hcl.TraverseAttr)
if !ok {
continue
}
return resourceRef{Type: root.Name, Name: attr.Name}, true
}
return resourceRef{}, false
}
// resolveLocalModule reads a module block's "source" and call arguments.
// Only a literal, relative-path source ("./..."/"../...", a local
// subdirectory) resolves -- a registry/git/absolute source returns
// ok=false, since there's no local directory to pull resources from.
// aliases maps each argument name to the resource it's a direct reference
// to (an argument passed some other way -- a literal, a data source, a
// computed expression -- is simply left out of the map, not an error).
func resolveLocalModule(dir string, block *hclsyntax.Block) (childDir string, aliases map[string]resourceRef, ok bool) {
srcAttr, hasSrc := block.Body.Attributes["source"]
if !hasSrc {
return "", nil, false
}
src, diags := srcAttr.Expr.Value(nil)
if diags.HasErrors() || src.Type() != cty.String {
return "", nil, false
}
srcStr := src.AsString()
if !strings.HasPrefix(srcStr, "./") && !strings.HasPrefix(srcStr, "../") {
return "", nil, false
}
aliases = map[string]resourceRef{}
for name, attr := range block.Body.Attributes {
if name == "source" {
continue
}
if ref, ok := resourceRefFromExpr(attr.Expr); ok {
aliases[name] = ref
}
}
return filepath.Clean(filepath.Join(dir, srcStr)), aliases, true
}
// scanTerraformDir scans dir's own .tf files as one Terraform module:
// shared locals/variables, and per-resource checks. Cross-resource checks
// (S3 bucket <-> its versioning/logging/encryption/public-access-block
// resources, VPC <-> its flow log) also pull in "attachment" resources
// from any directory dir references via a literal local module source
// (module "x" { source = "./..." }) -- resolving the child's `var.x`-style
// references back to dir's own resources through that module call's
// arguments. Each pulled-in block keeps its own directory's eval context
// (ctx), since a child module's locals/variables are a different
// namespace from dir's.
//
// ponytail ceiling: one level of module nesting (a module's own module
// blocks aren't followed), and only for this specific
// "attachment-resource-passed-a-parent-resource's-id" pattern -- not
// general module input/output resolution. A child module that declares
// its own aws_s3_bucket/aws_vpc (rather than referencing the parent's) is
// unaffected either way: it's still scanned as its own independent
// directory, same as before this existed.
func scanTerraformDir(dir string, allDirs map[string][]string) []model.Issue {
d := parseTFDir(allDirs[dir])
ctx := buildEvalContext(d.localsBlocks, d.variableBlocks)
for i := range d.resources {
d.resources[i].ctx = ctx
}
var issues []model.Issue
for _, r := range d.resources {
issues = append(issues, terraformResourceChecks(r.block, r.path, ctx)...)
}
combined := append([]tfBlock{}, d.resources...)
for _, mb := range d.moduleBlocks {
childDir, aliases, ok := resolveLocalModule(dir, mb.block)
if !ok {
continue
}
childFiles, ok := allDirs[childDir]
if !ok {
continue
}
cd := parseTFDir(childFiles)
childCtx := buildEvalContext(cd.localsBlocks, cd.variableBlocks)
for _, cr := range cd.resources {
if !terraformAttachmentResourceTypes[cr.block.Labels[0]] {
continue
}
cr.ctx, cr.aliases = childCtx, aliases
combined = append(combined, cr)
}
}
issues = append(issues, terraformS3CrossResourceChecks(combined)...)
issues = append(issues, terraformVPCFlowLogChecks(combined)...)
for _, ds := range d.dataSources {
issues = append(issues, terraformDataSourceChecks(ds.block, ds.path)...)
}
return issues
}
// buildEvalContext resolves `local.x` and `var.x` (variables with a
// literal default) so attribute checks can see through them. Locals may
// reference other locals or variables; since there's no dependency graph
// here, a few passes converge on the common case of a short chain.
func buildEvalContext(localsBlocks, variableBlocks []tfBlock) *hcl.EvalContext {
ctx := &hcl.EvalContext{Variables: map[string]cty.Value{}}
varVals := map[string]cty.Value{}
for _, vb := range variableBlocks {
def, ok := vb.block.Body.Attributes["default"]
if !ok {
continue
}
if v, diags := def.Expr.Value(nil); !diags.HasErrors() {
varVals[vb.block.Labels[0]] = v
}
}
ctx.Variables["var"] = cty.ObjectVal(varVals)
localVals := map[string]cty.Value{}
for range 5 {
ctx.Variables["local"] = cty.ObjectVal(localVals)
changed := false
for _, lb := range localsBlocks {
for name, attr := range lb.block.Body.Attributes {
v, diags := attr.Expr.Value(ctx)
if diags.HasErrors() {
continue
}
if existing, ok := localVals[name]; !ok || !existing.RawEquals(v) {
localVals[name] = v
changed = true
}
}
}
if !changed {
break
}
}
ctx.Variables["local"] = cty.ObjectVal(localVals)
return ctx
}
// resourceCheck inspects one resource block in isolation. Each check owns
// its own resource-type filter, so a resource type can be covered by
// several independent checks (e.g. aws_db_instance gets encryption,
// Performance Insights, and IAM-auth checks from three different funcs).
type resourceCheck func(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue
// terraformChecks is every registered check across all provider files
// (this file's AWS checks, terraform_azure.go's azurerm checks,
// terraform_gcp.go's google checks) -- combined here so
// terraformResourceChecks doesn't need to know about providers at all.
var terraformChecks = slices.Concat(awsResourceChecks, azureResourceChecks, gcpResourceChecks)
var awsResourceChecks = []resourceCheck{
checkS3PublicACL,
checkS3BucketNameDNSCompliant,
checkSecurityGroupRules,
checkStorageEncryption,
checkIAMWildcardPolicy,
checkIAMUserPolicyAttachment,
checkIAMPasswordPolicy,
checkIMDSv2,
checkEBSRootVolumeEncryption,
checkCloudWatchLogGroupEncryption,
checkALB,
checkLBListenerPlainHTTP,
checkLambdaXRay,
checkLambdaFunctionURLAuth,
checkRDSPerformanceInsightsAndIAMAuth,
checkECR,
checkSNSEncryption,
checkSQSEncryption,
checkSecretsManagerEncryption,
checkKMSRotation,
checkCloudFrontWAFAndLogging,
checkCloudFrontTLS,
checkAPIGatewayLogging,
checkAPIGatewayMethodAuth,
checkEKSCluster,
checkCloudTrail,
checkGuardDuty,
checkDynamoDBPITR,
checkElastiCacheEncryption,
checkRedshift,
checkEFSEncryption,
checkKinesisEncryption,
checkECSTaskDefinition,
}
func terraformResourceChecks(block *hclsyntax.Block, path string, ctx *hcl.EvalContext) []model.Issue {
resType, resName := block.Labels[0], block.Labels[1]
line := block.DefRange().Start.Line
var issues []model.Issue
for _, check := range terraformChecks {
issues = append(issues, check(resType, resName, path, line, block.Body, ctx)...)
}
return issues
}
func terraformDataSourceChecks(block *hclsyntax.Block, path string) []model.Issue {
if block.Labels[0] != "aws_ami" {
return nil
}
if _, ok := block.Body.Attributes["owners"]; ok {
return nil
}
return []model.Issue{newIssue("tf-ami-no-owners", "LOW", path, block.DefRange().Start.Line,
"aws_ami data source does not restrict owners",
"data.aws_ami."+block.Labels[1]+" has no owners filter")}
}
// --- S3 ---
func checkS3PublicACL(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_s3_bucket" {
return nil
}
if acl, ok := attrString(body, "acl", ctx); ok && (acl == "public-read" || acl == "public-read-write") {
return []model.Issue{newIssue("tf-s3-public-acl", "CRITICAL", path, line,
"S3 bucket has a public ACL", resType+"."+resName+" acl = \""+acl+"\"")}
}
return nil
}
var s3NameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$`)
func checkS3BucketNameDNSCompliant(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_s3_bucket" {
return nil
}
name, ok := attrString(body, "bucket", ctx)
if !ok || s3NameRe.MatchString(name) {
return nil
}
return []model.Issue{newIssue("tf-s3-bucket-name-not-dns-compliant", "MEDIUM", path, line,
"S3 bucket name is not DNS-compliant", resType+"."+resName+" bucket = \""+name+"\"")}
}
// terraformS3CrossResourceChecks handles the modern (provider v4+) style
// where versioning/encryption/logging/public-access-block are separate
// resources that reference their bucket via `bucket = aws_s3_bucket.x.id`,
// plus the older inline-block style on aws_s3_bucket itself. References
// are matched across every file in the directory (see scanTerraformDir).
func terraformS3CrossResourceChecks(resources []tfBlock) []model.Issue {
type bucketInfo struct {
block *hclsyntax.Block
path string
versioned bool
encrypted bool
logged bool
hasAccessBlock bool
accessBlockComplete bool
}
buckets := map[string]*bucketInfo{}
get := func(name string) *bucketInfo {
if b, ok := buckets[name]; ok {
return b
}
b := &bucketInfo{}
buckets[name] = b
return b
}
for _, rb := range resources {
r := rb.block
resType, resName := r.Labels[0], r.Labels[1]
switch resType {
case "aws_s3_bucket":
b := get(resName)
b.block = r
b.path = rb.path
for _, nested := range r.Body.Blocks {
switch nested.Type {
case "versioning":
if s, ok := attrString(nested.Body, "status", rb.ctx); ok && s == "Enabled" {
b.versioned = true
} else if e, ok := attrBool(nested.Body, "enabled", rb.ctx); ok && e {
b.versioned = true
}
case "server_side_encryption_configuration":
b.encrypted = true
case "logging":
b.logged = true
}
}
case "aws_s3_bucket_versioning":
name, ok := attrRefName(r.Body, "bucket", "aws_s3_bucket", rb.aliases)
if !ok {
continue
}
if vc := findBlockByType(r.Body, "versioning_configuration"); vc != nil {
if s, ok := attrString(vc.Body, "status", rb.ctx); ok && s == "Enabled" {
get(name).versioned = true
}
}
case "aws_s3_bucket_server_side_encryption_configuration":
if name, ok := attrRefName(r.Body, "bucket", "aws_s3_bucket", rb.aliases); ok {
get(name).encrypted = true
}
case "aws_s3_bucket_logging":
if name, ok := attrRefName(r.Body, "bucket", "aws_s3_bucket", rb.aliases); ok {
get(name).logged = true
}
case "aws_s3_bucket_public_access_block":
name, ok := attrRefName(r.Body, "bucket", "aws_s3_bucket", rb.aliases)
if !ok {
continue
}
b := get(name)
b.hasAccessBlock = true
complete := true
for _, attrName := range []string{"block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"} {
if v, ok := attrBool(r.Body, attrName, rb.ctx); !ok || !v {
complete = false
}
}
b.accessBlockComplete = complete
}
}
var issues []model.Issue
for name, b := range buckets {
if b.block == nil {
continue // referenced by a sub-resource but not declared in this directory (module input, etc.)
}
line := b.block.DefRange().Start.Line
addr := "aws_s3_bucket." + name
if !b.versioned {
issues = append(issues, newIssue("tf-s3-versioning-disabled", "MEDIUM", b.path, line,
"S3 bucket does not have versioning enabled", addr))
}
if !b.logged {
issues = append(issues, newIssue("tf-s3-logging-disabled", "LOW", b.path, line,
"S3 bucket does not have access logging enabled", addr))
}
if !b.encrypted {
issues = append(issues, newIssue("tf-s3-unencrypted", "MEDIUM", b.path, line,
"S3 bucket has no server-side encryption configuration", addr))
}
if !b.hasAccessBlock {
issues = append(issues, newIssue("tf-s3-missing-public-access-block", "HIGH", b.path, line,
"S3 bucket has no aws_s3_bucket_public_access_block resource", addr))
} else if !b.accessBlockComplete {
issues = append(issues, newIssue("tf-s3-public-access-block-incomplete", "HIGH", b.path, line,
"S3 bucket's public access block does not block all public access", addr))
}
}
return issues
}
// --- VPC / networking ---
// terraformVPCFlowLogChecks flags an aws_vpc with no aws_flow_log resource
// referencing it anywhere in the directory.
func terraformVPCFlowLogChecks(resources []tfBlock) []model.Issue {
type vpcInfo struct {
block *hclsyntax.Block
path string
resName string
logged bool
}
vpcs := map[string]*vpcInfo{}
for _, rb := range resources {
r := rb.block
if r.Labels[0] == "aws_vpc" {
vpcs[r.Labels[1]] = &vpcInfo{block: r, path: rb.path, resName: r.Labels[1]}
}
}
if len(vpcs) == 0 {
return nil
}
for _, rb := range resources {
r := rb.block
if r.Labels[0] != "aws_flow_log" {
continue
}
if name, ok := attrRefName(r.Body, "vpc_id", "aws_vpc", rb.aliases); ok {
if v, ok := vpcs[name]; ok {
v.logged = true
}
}
}
var issues []model.Issue
for _, v := range vpcs {
if !v.logged {
issues = append(issues, newIssue("tf-vpc-no-flow-log", "MEDIUM", v.path, v.block.DefRange().Start.Line,
"VPC does not have flow logging enabled", "aws_vpc."+v.resName))
}
}
return issues
}
func checkSecurityGroupRules(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
var issues []model.Issue
switch resType {
case "aws_security_group", "aws_default_security_group":
for _, b := range body.Blocks {
if b.Type == "ingress" || b.Type == "egress" {
issues = append(issues, sgRuleIssues(b.Type, b.Body, resType, resName, path, line, ctx)...)
}
}
case "aws_security_group_rule":
if kind, ok := attrString(body, "type", ctx); ok && (kind == "ingress" || kind == "egress") {
issues = append(issues, sgRuleIssues(kind, body, resType, resName, path, line, ctx)...)
}
}
return issues
}
func sgRuleIssues(kind string, body *hclsyntax.Body, resType, resName, path string, line int, ctx *hcl.EvalContext) []model.Issue {
var issues []model.Issue
if hasOpenCIDR(body, ctx) {
if kind == "egress" {
issues = append(issues, newIssue("tf-security-group-open-egress", "CRITICAL", path, line,
"Security group allows unrestricted egress to 0.0.0.0/0", resType+"."+resName))
} else {
issues = append(issues, newIssue("tf-security-group-open-ingress", "CRITICAL", path, line,
"Security group allows ingress from 0.0.0.0/0", resType+"."+resName))
}
}
if _, ok := attrString(body, "description", ctx); !ok {
issues = append(issues, newIssue("tf-security-group-rule-no-description", "LOW", path, line,
"Security group "+kind+" rule has no description", resType+"."+resName))
}
return issues
}
func hasOpenCIDR(body *hclsyntax.Body, ctx *hcl.EvalContext) bool {
return listAttrContainsOpenCIDR(body, ctx, "cidr_blocks", "ipv6_cidr_blocks")
}
// listAttrContainsOpenCIDR checks whether any of the given list-typed
// attributes contains a wide-open CIDR ("0.0.0.0/0" or "::/0"). Shared by
// AWS security group rules and GCP firewall rules, which both express
// source ranges as a plain string list (Azure's NSG rules use a single
// string attribute instead -- see azureNSGRuleOpen).
func listAttrContainsOpenCIDR(body *hclsyntax.Body, ctx *hcl.EvalContext, attrNames ...string) bool {
for _, attrName := range attrNames {
attr, ok := body.Attributes[attrName]
if !ok {
continue
}
val, diags := attr.Expr.Value(ctx)
if diags.HasErrors() || val.IsNull() || !val.CanIterateElements() {
continue
}
for it := val.ElementIterator(); it.Next(); {
_, v := it.Element()
if v.Type() != cty.String {
continue
}
if s := v.AsString(); s == "0.0.0.0/0" || s == "::/0" {
return true
}
}
}
return false
}
// --- Storage / database ---
func checkStorageEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
switch resType {
case "aws_db_instance":
if enc, ok := attrBool(body, "storage_encrypted", ctx); !ok || !enc {
return []model.Issue{newIssue("tf-unencrypted-storage", "HIGH", path, line,
"Storage is not encrypted", resType+"."+resName+" storage_encrypted is not true")}
}
case "aws_ebs_volume":
if enc, ok := attrBool(body, "encrypted", ctx); !ok || !enc {
return []model.Issue{newIssue("tf-unencrypted-storage", "HIGH", path, line,
"Storage is not encrypted", resType+"."+resName+" encrypted is not true")}
}
default:
return nil
}
return nil
}
func checkEBSRootVolumeEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_instance" && resType != "aws_launch_template" {
return nil
}
rbd := findBlockByType(body, "root_block_device")
if rbd == nil {
return nil // no explicit root_block_device -> can't tell, provider/AMI default varies
}
if enc, ok := attrBool(rbd.Body, "encrypted", ctx); !ok || !enc {
return []model.Issue{newIssue("tf-ebs-root-volume-unencrypted", "MEDIUM", path, line,
"Root block device is not encrypted", resType+"."+resName+" root_block_device.encrypted is not true")}
}
return nil
}
func checkRDSPerformanceInsightsAndIAMAuth(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_db_instance" {
return nil
}
var issues []model.Issue
if pi, ok := attrBool(body, "performance_insights_enabled", ctx); !ok || !pi {
issues = append(issues, newIssue("tf-rds-performance-insights-disabled", "LOW", path, line,
"RDS instance does not have Performance Insights enabled", resType+"."+resName))
} else if _, ok := attrString(body, "performance_insights_kms_key_id", ctx); !ok {
issues = append(issues, newIssue("tf-rds-performance-insights-not-cmk", "LOW", path, line,
"RDS Performance Insights is not encrypted with a customer-managed key", resType+"."+resName))
}
if engine, ok := attrString(body, "engine", ctx); ok && (strings.HasPrefix(engine, "mysql") || strings.HasPrefix(engine, "postgres")) {
if iamAuth, ok := attrBool(body, "iam_database_authentication_enabled", ctx); !ok || !iamAuth {
issues = append(issues, newIssue("tf-rds-iam-auth-disabled", "MEDIUM", path, line,
"RDS instance does not have IAM database authentication enabled", resType+"."+resName))
}
}
return issues
}
func checkDynamoDBPITR(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_dynamodb_table" {
return nil
}
pitr := findBlockByType(body, "point_in_time_recovery")
if pitr != nil {
if enabled, ok := attrBool(pitr.Body, "enabled", ctx); ok && enabled {
return nil
}
}
return []model.Issue{newIssue("tf-dynamodb-pitr-disabled", "MEDIUM", path, line,
"DynamoDB table does not have point-in-time recovery enabled", resType+"."+resName)}
}
func checkElastiCacheEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_elasticache_replication_group" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "at_rest_encryption_enabled", ctx); !ok || !v {
issues = append(issues, newIssue("tf-elasticache-not-encrypted-at-rest", "HIGH", path, line,
"ElastiCache replication group is not encrypted at rest", resType+"."+resName))
}
if v, ok := attrBool(body, "transit_encryption_enabled", ctx); !ok || !v {
issues = append(issues, newIssue("tf-elasticache-not-encrypted-in-transit", "HIGH", path, line,
"ElastiCache replication group does not encrypt data in transit", resType+"."+resName))
}
return issues
}
func checkRedshift(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_redshift_cluster" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "encrypted", ctx); !ok || !v {
issues = append(issues, newIssue("tf-redshift-unencrypted", "HIGH", path, line,
"Redshift cluster is not encrypted", resType+"."+resName))
}
if v, ok := attrBool(body, "publicly_accessible", ctx); ok && v {
issues = append(issues, newIssue("tf-redshift-publicly-accessible", "HIGH", path, line,
"Redshift cluster is publicly accessible", resType+"."+resName))
}
return issues
}
func checkEFSEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_efs_file_system" {
return nil
}
if v, ok := attrBool(body, "encrypted", ctx); !ok || !v {
return []model.Issue{newIssue("tf-efs-unencrypted", "HIGH", path, line,
"EFS file system is not encrypted", resType+"."+resName)}
}
return nil
}
func checkKinesisEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_kinesis_stream" {
return nil
}
if t, ok := attrString(body, "encryption_type", ctx); !ok || t != "KMS" {
return []model.Issue{newIssue("tf-kinesis-not-encrypted", "MEDIUM", path, line,
"Kinesis stream is not encrypted with KMS", resType+"."+resName)}
}
return nil
}
// --- IAM ---
func checkIAMWildcardPolicy(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_iam_policy" && resType != "aws_iam_role_policy" && resType != "aws_iam_user_policy" {
return nil
}
policy, ok := attrString(body, "policy", ctx)
if !ok {
return nil
}
var issues []model.Issue
fullResource := strings.Contains(policy, `"Resource": "*"`) || strings.Contains(policy, `"Resource":"*"`)
if strings.Contains(policy, `"Action": "*"`) && fullResource {
issues = append(issues, newIssue("tf-iam-wildcard-policy", "CRITICAL", path, line,
"IAM policy grants Action=* on Resource=*", resType+"."+resName))
}
if strings.Contains(policy, `"s3:*"`) && fullResource {
issues = append(issues, newIssue("tf-iam-s3-wildcard-policy", "HIGH", path, line,
"IAM policy grants unrestricted S3 access (s3:*) on all resources", resType+"."+resName))
}
if strings.Contains(policy, `"iam:PassRole"`) && fullResource && !strings.Contains(policy, `"Condition"`) {
issues = append(issues, newIssue("tf-iam-passrole-unrestricted", "MEDIUM", path, line,
"IAM policy grants iam:PassRole on all resources with no condition", resType+"."+resName))
}
return issues
}
func checkIAMUserPolicyAttachment(resType, resName, path string, line int, _ *hclsyntax.Body, _ *hcl.EvalContext) []model.Issue {
if resType != "aws_iam_user_policy" && resType != "aws_iam_user_policy_attachment" {
return nil
}
return []model.Issue{newIssue("tf-iam-policy-attached-to-user", "LOW", path, line,
"IAM policy is attached directly to a user instead of a role or group", resType+"."+resName)}
}
func checkIAMPasswordPolicy(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_iam_account_password_policy" {
return nil
}
var issues []model.Issue
if n, ok := attrNumber(body, "minimum_password_length", ctx); !ok || n < 14 {
issues = append(issues, newIssue("tf-iam-weak-password-policy", "MEDIUM", path, line,
"IAM account password policy allows short passwords (minimum_password_length < 14)", resType+"."+resName))
}
for _, attrName := range []string{"require_lowercase_characters", "require_uppercase_characters", "require_numbers", "require_symbols"} {
if v, ok := attrBool(body, attrName, ctx); !ok || !v {
issues = append(issues, newIssue("tf-iam-weak-password-policy", "LOW", path, line,
"IAM account password policy does not require "+attrName, resType+"."+resName))
}
}
return issues
}
// --- Compute ---
func checkIMDSv2(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_instance" && resType != "aws_launch_template" {
return nil
}
mo := findBlockByType(body, "metadata_options")
if mo == nil {
return []model.Issue{newIssue("tf-imdsv1-enabled", "HIGH", path, line,
"Instance metadata service allows IMDSv1 (no metadata_options block)",
resType+"."+resName+" has no metadata_options block; http_tokens defaults to \"optional\"")}
}
tokens, ok := attrString(mo.Body, "http_tokens", ctx)
shown := tokens
if !ok {
shown = "<absent>"
}
if !ok || tokens != "required" {
return []model.Issue{newIssue("tf-imdsv1-enabled", "HIGH", path, line,
"Instance metadata service allows IMDSv1 (http_tokens != \"required\")",
resType+"."+resName+" metadata_options.http_tokens = \""+shown+"\"")}
}
return nil
}
func checkLambdaXRay(resType, resName, path string, line int, body *hclsyntax.Body, _ *hcl.EvalContext) []model.Issue {
if resType != "aws_lambda_function" {
return nil
}
if findBlockByType(body, "tracing_config") == nil {
return []model.Issue{newIssue("tf-lambda-no-xray-tracing", "LOW", path, line,
"Lambda function does not have X-Ray tracing enabled", resType+"."+resName)}
}
return nil
}
func checkLambdaFunctionURLAuth(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_lambda_function_url" {
return nil
}
if t, ok := attrString(body, "authorization_type", ctx); ok && t == "NONE" {
return []model.Issue{newIssue("tf-lambda-function-url-no-auth", "HIGH", path, line,
"Lambda function URL has no authorization", resType+"."+resName)}
}
return nil
}
func checkECSTaskDefinition(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_ecs_task_definition" {
return nil
}
def, ok := attrString(body, "container_definitions", ctx)
if !ok {
return nil
}
var issues []model.Issue
if strings.Contains(def, `"privileged": true`) || strings.Contains(def, `"privileged":true`) {
issues = append(issues, newIssue("tf-ecs-privileged-container", "HIGH", path, line,
"ECS task definition runs a privileged container", resType+"."+resName))
}
if !strings.Contains(def, `"readonlyRootFilesystem": true`) && !strings.Contains(def, `"readonlyRootFilesystem":true`) {
issues = append(issues, newIssue("tf-ecs-no-readonly-root-fs", "LOW", path, line,
"ECS task definition does not set a read-only root filesystem", resType+"."+resName))
}
return issues
}
// --- Load balancing ---
func checkALB(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_lb" && resType != "aws_alb" {
return nil
}
var issues []model.Issue
if lbType, _ := attrString(body, "load_balancer_type", ctx); lbType == "" || lbType == "application" {
if drop, ok := attrBool(body, "drop_invalid_header_fields", ctx); !ok || !drop {
issues = append(issues, newIssue("tf-alb-invalid-headers-allowed", "HIGH", path, line,
"ALB does not drop invalid HTTP headers", resType+"."+resName+" drop_invalid_header_fields is not true"))
}
}
if internal, ok := attrBool(body, "internal", ctx); !ok || !internal {
issues = append(issues, newIssue("tf-lb-internet-facing", "HIGH", path, line,
"Load balancer is internet-facing", resType+"."+resName+" internal is not true"))
}
return issues
}
func checkLBListenerPlainHTTP(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_lb_listener" && resType != "aws_alb_listener" {
return nil
}
if proto, ok := attrString(body, "protocol", ctx); ok && proto == "HTTP" {
return []model.Issue{newIssue("tf-lb-listener-plain-http", "CRITICAL", path, line,
"Load balancer listener uses plain HTTP", resType+"."+resName+" protocol = \"HTTP\"")}
}
return nil
}
// --- API Gateway ---
func checkAPIGatewayLogging(resType, resName, path string, line int, body *hclsyntax.Body, _ *hcl.EvalContext) []model.Issue {
if resType != "aws_api_gateway_stage" && resType != "aws_apigatewayv2_stage" {
return nil
}
if findBlockByType(body, "access_log_settings") == nil {
return []model.Issue{newIssue("tf-apigateway-no-access-logging", "MEDIUM", path, line,
"API Gateway stage does not have access logging enabled", resType+"."+resName)}
}
return nil
}
func checkAPIGatewayMethodAuth(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_api_gateway_method" {
return nil
}
if auth, ok := attrString(body, "authorization", ctx); ok && auth == "NONE" {
return []model.Issue{newIssue("tf-apigateway-method-no-auth", "MEDIUM", path, line,
"API Gateway method has no authorization", resType+"."+resName)}
}
return nil
}
// --- Other AWS services ---
func checkCloudWatchLogGroupEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_cloudwatch_log_group" {
return nil
}
if _, ok := attrString(body, "kms_key_id", ctx); !ok {
return []model.Issue{newIssue("tf-cloudwatch-log-group-unencrypted", "LOW", path, line,
"CloudWatch log group is not encrypted with a customer-managed KMS key", resType+"."+resName)}
}
return nil
}
func checkECR(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_ecr_repository" {
return nil
}
var issues []model.Issue
if m, ok := attrString(body, "image_tag_mutability", ctx); !ok || m != "IMMUTABLE" {
issues = append(issues, newIssue("tf-ecr-tag-mutable", "HIGH", path, line,
"ECR repository allows mutable image tags", resType+"."+resName))
}
notCMK := true
if enc := findBlockByType(body, "encryption_configuration"); enc != nil {
if t, ok := attrString(enc.Body, "encryption_type", ctx); ok && t == "KMS" {
notCMK = false
}
}
if notCMK {
issues = append(issues, newIssue("tf-ecr-not-cmk-encrypted", "LOW", path, line,
"ECR repository is not encrypted with a customer-managed KMS key", resType+"."+resName))
}
return issues
}
func checkSNSEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_sns_topic" {
return nil
}
if _, ok := attrString(body, "kms_master_key_id", ctx); !ok {
return []model.Issue{newIssue("tf-sns-unencrypted", "HIGH", path, line,
"SNS topic is not encrypted", resType+"."+resName)}
}
return nil
}
func checkSQSEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_sqs_queue" {
return nil
}
if _, ok := attrString(body, "kms_master_key_id", ctx); !ok {
return []model.Issue{newIssue("tf-sqs-not-cmk", "LOW", path, line,
"SQS queue is not encrypted with a customer-managed key", resType+"."+resName)}
}
return nil
}
func checkSecretsManagerEncryption(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_secretsmanager_secret" {
return nil
}
if _, ok := attrString(body, "kms_key_id", ctx); !ok {
return []model.Issue{newIssue("tf-secretsmanager-not-cmk", "LOW", path, line,
"Secrets Manager secret is not encrypted with a customer-managed key", resType+"."+resName)}
}
return nil
}
func checkKMSRotation(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_kms_key" {
return nil
}
if r, ok := attrBool(body, "enable_key_rotation", ctx); !ok || !r {
return []model.Issue{newIssue("tf-kms-rotation-disabled", "MEDIUM", path, line,
"KMS key does not have automatic rotation enabled", resType+"."+resName)}
}
return nil
}
func checkCloudFrontWAFAndLogging(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_cloudfront_distribution" {
return nil
}
var issues []model.Issue
if _, ok := attrString(body, "web_acl_id", ctx); !ok {
issues = append(issues, newIssue("tf-cloudfront-no-waf", "HIGH", path, line,
"CloudFront distribution has no WAF association", resType+"."+resName))
}
if findBlockByType(body, "logging_config") == nil {
issues = append(issues, newIssue("tf-cloudfront-no-logging", "MEDIUM", path, line,
"CloudFront distribution does not have access logging configured", resType+"."+resName))
}
return issues
}
func checkCloudFrontTLS(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_cloudfront_distribution" {
return nil
}
var issues []model.Issue
if vc := findBlockByType(body, "viewer_certificate"); vc != nil {
if v, ok := attrString(vc.Body, "minimum_protocol_version", ctx); !ok || !strings.HasPrefix(v, "TLSv1.2") {
issues = append(issues, newIssue("tf-cloudfront-weak-tls", "MEDIUM", path, line,
"CloudFront distribution does not enforce TLSv1.2+", resType+"."+resName))
}
}
behaviors := []*hclsyntax.Block{}
if b := findBlockByType(body, "default_cache_behavior"); b != nil {
behaviors = append(behaviors, b)
}
for _, b := range body.Blocks {
if b.Type == "ordered_cache_behavior" {
behaviors = append(behaviors, b)
}
}
for _, b := range behaviors {
if p, ok := attrString(b.Body, "viewer_protocol_policy", ctx); ok && p == "allow-all" {
issues = append(issues, newIssue("tf-cloudfront-plain-http", "HIGH", path, line,
"CloudFront cache behavior allows plain HTTP (viewer_protocol_policy = \"allow-all\")", resType+"."+resName))
break
}
}
return issues
}
func checkEKSCluster(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_eks_cluster" {
return nil
}
var issues []model.Issue
vc := findBlockByType(body, "vpc_config")
if vc == nil {
issues = append(issues, newIssue("tf-eks-public-endpoint", "HIGH", path, line,
"EKS cluster API server endpoint is publicly accessible (no vpc_config block)", resType+"."+resName))
} else if pub, ok := attrBool(vc.Body, "endpoint_public_access", ctx); !ok || pub {
issues = append(issues, newIssue("tf-eks-public-endpoint", "HIGH", path, line,
"EKS cluster API server endpoint is publicly accessible", resType+"."+resName))
}
if _, ok := body.Attributes["enabled_cluster_log_types"]; !ok {
issues = append(issues, newIssue("tf-eks-no-control-plane-logging", "LOW", path, line,
"EKS cluster does not have control plane logging enabled", resType+"."+resName))
}
return issues
}
func checkCloudTrail(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_cloudtrail" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "enable_log_file_validation", ctx); !ok || !v {
issues = append(issues, newIssue("tf-cloudtrail-no-log-validation", "MEDIUM", path, line,
"CloudTrail trail does not have log file validation enabled", resType+"."+resName))
}
if v, ok := attrBool(body, "is_multi_region_trail", ctx); !ok || !v {
issues = append(issues, newIssue("tf-cloudtrail-not-multi-region", "LOW", path, line,
"CloudTrail trail is not multi-region", resType+"."+resName))
}
if _, ok := attrString(body, "kms_key_id", ctx); !ok {
issues = append(issues, newIssue("tf-cloudtrail-not-cmk", "LOW", path, line,
"CloudTrail trail is not encrypted with a customer-managed key", resType+"."+resName))
}
return issues
}
func checkGuardDuty(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "aws_guardduty_detector" {
return nil
}
if v, ok := attrBool(body, "enable", ctx); ok && !v {
return []model.Issue{newIssue("tf-guardduty-disabled", "HIGH", path, line,
"GuardDuty detector is explicitly disabled", resType+"."+resName)}
}
return nil
}
// --- HCL helpers ---
func findBlockByType(body *hclsyntax.Body, t string) *hclsyntax.Block {
for _, b := range body.Blocks {
if b.Type == t {
return b
}
}
return nil
}
// attrRefName returns the resource-local name referenced by attrName when
// its expression is (or contains) a traversal rooted at wantType, e.g.
// `bucket = aws_s3_bucket.data.id` with wantType "aws_s3_bucket" -> "data".
// If the expression instead references a module input variable
// (`bucket = var.bucket_id`) that aliases maps to a wantType resource --
// i.e. this block was pulled in from a child module whose caller passed
// that resource's id as an argument -- resolves through the alias too.
func attrRefName(body *hclsyntax.Body, attrName, wantType string, aliases map[string]resourceRef) (string, bool) {
attr, ok := body.Attributes[attrName]
if !ok {
return "", false
}
ref, ok := resourceRefFromExpr(attr.Expr)
if !ok {
return "", false
}
if ref.Type == wantType {
return ref.Name, true
}
if ref.Type == "var" {
if aliased, ok := aliases[ref.Name]; ok && aliased.Type == wantType {
return aliased.Name, true
}
}
return "", false
}
func attrString(body *hclsyntax.Body, name string, ctx *hcl.EvalContext) (string, bool) {
attr, ok := body.Attributes[name]
if !ok {
return "", false
}
val, diags := attr.Expr.Value(ctx)
if diags.HasErrors() || val.Type() != cty.String {
return "", false
}
return val.AsString(), true
}
func attrBool(body *hclsyntax.Body, name string, ctx *hcl.EvalContext) (bool, bool) {
attr, ok := body.Attributes[name]
if !ok {
return false, false
}
val, diags := attr.Expr.Value(ctx)
if diags.HasErrors() || val.Type() != cty.Bool {
return false, false
}
return val.True(), true
}
func attrNumber(body *hclsyntax.Body, name string, ctx *hcl.EvalContext) (float64, bool) {
attr, ok := body.Attributes[name]
if !ok {
return 0, false
}
val, diags := attr.Expr.Value(ctx)
if diags.HasErrors() || val.Type() != cty.Number {
return 0, false
}
f, _ := val.AsBigFloat().Float64()
return f, true
}
package misconfig
import (
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/colibrisec/ojo/internal/model"
)
// Azure (azurerm provider) checks. Same resourceCheck shape, same helpers
// (attrString/attrBool/findBlockByType) as the AWS checks in terraform.go.
var azureResourceChecks = []resourceCheck{
checkAzureStorageAccount,
checkAzureStorageContainerPublicAccess,
checkAzureNSGRules,
checkAzureKeyVault,
checkAzureSQLServer,
checkAzurePostgreSQL,
checkAzureAKS,
checkAzureAppService,
checkAzureACR,
checkAzureCosmosDB,
checkAzureRedis,
}
func checkAzureStorageAccount(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_storage_account" {
return nil
}
var issues []model.Issue
// Provider renamed enable_https_traffic_only -> https_traffic_only_enabled.
httpsOnly, ok := attrBool(body, "https_traffic_only_enabled", ctx)
if !ok {
httpsOnly, ok = attrBool(body, "enable_https_traffic_only", ctx)
}
if !ok || !httpsOnly {
issues = append(issues, newIssue("tf-azure-storage-insecure-transport", "HIGH", path, line,
"Storage account allows plain HTTP traffic", resType+"."+resName))
}
if v, ok := attrString(body, "min_tls_version", ctx); !ok || v != "TLS1_2" {
issues = append(issues, newIssue("tf-azure-storage-weak-tls", "MEDIUM", path, line,
"Storage account allows TLS versions older than 1.2", resType+"."+resName))
}
// Provider renamed allow_blob_public_access -> allow_nested_items_to_be_public.
pub, ok := attrBool(body, "allow_nested_items_to_be_public", ctx)
if !ok {
pub, ok = attrBool(body, "allow_blob_public_access", ctx)
}
if ok && pub {
issues = append(issues, newIssue("tf-azure-storage-public-blob-access", "HIGH", path, line,
"Storage account allows public access to blob containers", resType+"."+resName))
}
if nr := findBlockByType(body, "network_rules"); nr != nil {
if def, ok := attrString(nr.Body, "default_action", ctx); ok && def == "Allow" {
issues = append(issues, newIssue("tf-azure-storage-network-open", "MEDIUM", path, line,
"Storage account network rules default to Allow, permitting access from any network", resType+"."+resName))
}
}
return issues
}
func checkAzureStorageContainerPublicAccess(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_storage_container" {
return nil
}
if v, ok := attrString(body, "container_access_type", ctx); ok && v != "private" {
return []model.Issue{newIssue("tf-azure-storage-container-public", "HIGH", path, line,
"Storage container allows anonymous/public access", resType+"."+resName+" container_access_type = \""+v+"\"")}
}
return nil
}
func checkAzureNSGRules(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
var issues []model.Issue
switch resType {
case "azurerm_network_security_group":
for _, b := range body.Blocks {
if b.Type == "security_rule" {
if issue, ok := azureNSGRuleOpen(b.Body, resType, resName, path, line, ctx); ok {
issues = append(issues, issue)
}
}
}
case "azurerm_network_security_rule":
if issue, ok := azureNSGRuleOpen(body, resType, resName, path, line, ctx); ok {
issues = append(issues, issue)
}
}
return issues
}
func azureNSGRuleOpen(body *hclsyntax.Body, resType, resName, path string, line int, ctx *hcl.EvalContext) (model.Issue, bool) {
direction, _ := attrString(body, "direction", ctx)
access, _ := attrString(body, "access", ctx)
if direction != "Inbound" || access != "Allow" {
return model.Issue{}, false
}
src, ok := attrString(body, "source_address_prefix", ctx)
if !ok || (src != "*" && src != "0.0.0.0/0" && src != "Internet" && src != "Any") {
return model.Issue{}, false
}
return newIssue("tf-azure-nsg-open-inbound", "CRITICAL", path, line,
"Network security rule allows unrestricted inbound access", resType+"."+resName), true
}
func checkAzureKeyVault(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_key_vault" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "purge_protection_enabled", ctx); !ok || !v {
issues = append(issues, newIssue("tf-azure-keyvault-no-purge-protection", "MEDIUM", path, line,
"Key Vault does not have purge protection enabled", resType+"."+resName))
}
if nr := findBlockByType(body, "network_acls"); nr != nil {
if def, ok := attrString(nr.Body, "default_action", ctx); ok && def == "Allow" {
issues = append(issues, newIssue("tf-azure-keyvault-network-open", "MEDIUM", path, line,
"Key Vault network ACLs default to Allow, permitting access from any network", resType+"."+resName))
}
}
return issues
}
func checkAzureSQLServer(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_mssql_server" && resType != "azurerm_sql_server" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "public_network_access_enabled", ctx); !ok || v {
issues = append(issues, newIssue("tf-azure-sql-public-access", "HIGH", path, line,
"SQL server allows public network access", resType+"."+resName))
}
if v, ok := attrString(body, "minimum_tls_version", ctx); !ok || (v != "1.2" && v != "TLS1_2") {
issues = append(issues, newIssue("tf-azure-sql-weak-tls", "MEDIUM", path, line,
"SQL server allows TLS versions older than 1.2", resType+"."+resName))
}
return issues
}
func checkAzurePostgreSQL(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_postgresql_server" && resType != "azurerm_mysql_server" {
return nil
}
if v, ok := attrBool(body, "ssl_enforcement_enabled", ctx); !ok || !v {
return []model.Issue{newIssue("tf-azure-db-ssl-disabled", "HIGH", path, line,
"Database server does not enforce SSL", resType+"."+resName)}
}
return nil
}
func checkAzureAKS(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_kubernetes_cluster" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "private_cluster_enabled", ctx); !ok || !v {
issues = append(issues, newIssue("tf-azure-aks-public-api", "HIGH", path, line,
"AKS cluster API server is publicly accessible", resType+"."+resName))
}
if findBlockByType(body, "network_profile") == nil {
issues = append(issues, newIssue("tf-azure-aks-no-network-policy", "LOW", path, line,
"AKS cluster has no network_profile (no network policy) configured", resType+"."+resName))
}
return issues
}
func checkAzureAppService(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
switch resType {
case "azurerm_app_service", "azurerm_linux_web_app", "azurerm_windows_web_app":
default:
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "https_only", ctx); !ok || !v {
issues = append(issues, newIssue("tf-azure-appservice-http-allowed", "HIGH", path, line,
"App Service does not enforce HTTPS only", resType+"."+resName))
}
if sc := findBlockByType(body, "site_config"); sc != nil {
if v, ok := attrString(sc.Body, "minimum_tls_version", ctx); ok && v != "1.2" {
issues = append(issues, newIssue("tf-azure-appservice-weak-tls", "MEDIUM", path, line,
"App Service allows TLS versions older than 1.2", resType+"."+resName))
}
}
return issues
}
func checkAzureACR(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_container_registry" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "public_network_access_enabled", ctx); !ok || v {
issues = append(issues, newIssue("tf-azure-acr-public-access", "MEDIUM", path, line,
"Container registry allows public network access", resType+"."+resName))
}
if v, ok := attrBool(body, "admin_enabled", ctx); ok && v {
issues = append(issues, newIssue("tf-azure-acr-admin-enabled", "MEDIUM", path, line,
"Container registry has admin (shared-key) access enabled", resType+"."+resName))
}
return issues
}
func checkAzureCosmosDB(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_cosmosdb_account" {
return nil
}
if v, ok := attrBool(body, "public_network_access_enabled", ctx); !ok || v {
return []model.Issue{newIssue("tf-azure-cosmosdb-public-access", "MEDIUM", path, line,
"Cosmos DB account allows public network access", resType+"."+resName)}
}
return nil
}
func checkAzureRedis(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "azurerm_redis_cache" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "enable_non_ssl_port", ctx); ok && v {
issues = append(issues, newIssue("tf-azure-redis-non-ssl", "HIGH", path, line,
"Redis cache allows non-SSL connections", resType+"."+resName))
}
if v, ok := attrString(body, "minimum_tls_version", ctx); ok && v != "1.2" {
issues = append(issues, newIssue("tf-azure-redis-weak-tls", "MEDIUM", path, line,
"Redis cache allows TLS versions older than 1.2", resType+"."+resName))
}
return issues
}
package misconfig
import (
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/colibrisec/ojo/internal/model"
)
// GCP (google provider) checks. Same resourceCheck shape, same helpers
// (attrString/attrBool/findBlockByType) as the AWS checks in terraform.go.
var gcpResourceChecks = []resourceCheck{
checkGCPStorageBucket,
checkGCPPublicIAMBinding,
checkGCPFirewall,
checkGCPComputeInstance,
checkGCPCloudSQL,
checkGCPKMSRotation,
checkGCPGKECluster,
checkGCPPubSubEncryption,
}
func checkGCPStorageBucket(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "google_storage_bucket" {
return nil
}
var issues []model.Issue
if v, ok := attrBool(body, "uniform_bucket_level_access", ctx); !ok || !v {
issues = append(issues, newIssue("tf-gcp-gcs-no-uniform-access", "MEDIUM", path, line,
"GCS bucket does not enforce uniform bucket-level access", resType+"."+resName))
}
versioned := false
if v := findBlockByType(body, "versioning"); v != nil {
if enabled, ok := attrBool(v.Body, "enabled", ctx); ok && enabled {
versioned = true
}
}
if !versioned {
issues = append(issues, newIssue("tf-gcp-gcs-no-versioning", "LOW", path, line,
"GCS bucket does not have versioning enabled", resType+"."+resName))
}
return issues
}
// checkGCPPublicIAMBinding flags an IAM binding/member granting access to
// allUsers/allAuthenticatedUsers -- public access to a GCP project, bucket,
// or other resource, regardless of which role is granted.
func checkGCPPublicIAMBinding(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
switch resType {
case "google_project_iam_binding", "google_storage_bucket_iam_binding", "google_project_iam_member", "google_storage_bucket_iam_member":
default:
return nil
}
members := []string{}
if m, ok := attrString(body, "member", ctx); ok {
members = append(members, m)
}
if attr, ok := body.Attributes["members"]; ok {
if val, diags := attr.Expr.Value(ctx); !diags.HasErrors() && val.CanIterateElements() {
for it := val.ElementIterator(); it.Next(); {
_, v := it.Element()
if v.Type().FriendlyName() == "string" {
members = append(members, v.AsString())
}
}
}
}
for _, m := range members {
if m == "allUsers" || m == "allAuthenticatedUsers" {
return []model.Issue{newIssue("tf-gcp-iam-public-member", "CRITICAL", path, line,
"IAM binding grants access to "+m, resType+"."+resName)}
}
}
return nil
}
func checkGCPFirewall(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "google_compute_firewall" {
return nil
}
if findBlockByType(body, "allow") == nil {
return nil // deny-only rule, or no rule body we can evaluate
}
if !listAttrContainsOpenCIDR(body, ctx, "source_ranges") {
return nil
}
return []model.Issue{newIssue("tf-gcp-firewall-open-ingress", "CRITICAL", path, line,
"Firewall rule allows unrestricted ingress from 0.0.0.0/0", resType+"."+resName)}
}
func checkGCPComputeInstance(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "google_compute_instance" && resType != "google_compute_instance_template" {
return nil
}
var issues []model.Issue
if findBlockByType(body, "shielded_instance_config") == nil {
issues = append(issues, newIssue("tf-gcp-compute-no-shielded-vm", "LOW", path, line,
"Compute instance does not have Shielded VM features enabled", resType+"."+resName))
}
for _, ni := range body.Blocks {
if ni.Type != "network_interface" {
continue
}
if findBlockByType(ni.Body, "access_config") != nil {
issues = append(issues, newIssue("tf-gcp-compute-public-ip", "MEDIUM", path, line,
"Compute instance has a public IP address", resType+"."+resName))
break
}
}
if sa := findBlockByType(body, "service_account"); sa != nil {
if attr, ok := sa.Body.Attributes["scopes"]; ok {
if val, diags := attr.Expr.Value(ctx); !diags.HasErrors() && val.CanIterateElements() {
for it := val.ElementIterator(); it.Next(); {
_, v := it.Element()
if v.Type().FriendlyName() == "string" && strings.Contains(v.AsString(), "cloud-platform") {
issues = append(issues, newIssue("tf-gcp-compute-broad-scope", "MEDIUM", path, line,
"Compute instance service account has the overly broad cloud-platform scope", resType+"."+resName))
break
}
}
}
}
}
return issues
}
func checkGCPCloudSQL(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "google_sql_database_instance" {
return nil
}
settings := findBlockByType(body, "settings")
if settings == nil {
return nil
}
var issues []model.Issue
if bc := findBlockByType(settings.Body, "backup_configuration"); bc == nil {
issues = append(issues, newIssue("tf-gcp-cloudsql-no-backups", "MEDIUM", path, line,
"Cloud SQL instance does not have backups enabled", resType+"."+resName))
} else if enabled, ok := attrBool(bc.Body, "enabled", ctx); !ok || !enabled {
issues = append(issues, newIssue("tf-gcp-cloudsql-no-backups", "MEDIUM", path, line,
"Cloud SQL instance does not have backups enabled", resType+"."+resName))
}
if ipc := findBlockByType(settings.Body, "ip_configuration"); ipc != nil {
if v, ok := attrBool(ipc.Body, "ipv4_enabled", ctx); !ok || v {
issues = append(issues, newIssue("tf-gcp-cloudsql-public-ip", "HIGH", path, line,
"Cloud SQL instance has a public IP address", resType+"."+resName))
}
if v, ok := attrBool(ipc.Body, "require_ssl", ctx); !ok || !v {
issues = append(issues, newIssue("tf-gcp-cloudsql-ssl-not-required", "MEDIUM", path, line,
"Cloud SQL instance does not require SSL connections", resType+"."+resName))
}
}
return issues
}
func checkGCPKMSRotation(resType, resName, path string, line int, body *hclsyntax.Body, _ *hcl.EvalContext) []model.Issue {
if resType != "google_kms_crypto_key" {
return nil
}
if _, ok := body.Attributes["rotation_period"]; !ok {
return []model.Issue{newIssue("tf-gcp-kms-no-rotation", "MEDIUM", path, line,
"KMS key does not have automatic rotation configured", resType+"."+resName)}
}
return nil
}
func checkGCPGKECluster(resType, resName, path string, line int, body *hclsyntax.Body, ctx *hcl.EvalContext) []model.Issue {
if resType != "google_container_cluster" {
return nil
}
var issues []model.Issue
if findBlockByType(body, "private_cluster_config") == nil {
issues = append(issues, newIssue("tf-gcp-gke-not-private", "HIGH", path, line,
"GKE cluster does not have private nodes/endpoint configured", resType+"."+resName))
}
if findBlockByType(body, "master_authorized_networks_config") == nil {
issues = append(issues, newIssue("tf-gcp-gke-no-authorized-networks", "MEDIUM", path, line,
"GKE cluster API server has no authorized network restriction", resType+"."+resName))
}
if np := findBlockByType(body, "network_policy"); np == nil {
issues = append(issues, newIssue("tf-gcp-gke-no-network-policy", "LOW", path, line,
"GKE cluster does not have network policy enabled", resType+"."+resName))
} else if enabled, ok := attrBool(np.Body, "enabled", ctx); !ok || !enabled {
issues = append(issues, newIssue("tf-gcp-gke-no-network-policy", "LOW", path, line,
"GKE cluster does not have network policy enabled", resType+"."+resName))
}
if v, ok := attrBool(body, "enable_legacy_abac", ctx); ok && v {
issues = append(issues, newIssue("tf-gcp-gke-legacy-abac", "MEDIUM", path, line,
"GKE cluster has legacy ABAC authorization enabled", resType+"."+resName))
}
return issues
}
func checkGCPPubSubEncryption(resType, resName, path string, line int, body *hclsyntax.Body, _ *hcl.EvalContext) []model.Issue {
if resType != "google_pubsub_topic" {
return nil
}
if _, ok := body.Attributes["kms_key_name"]; !ok {
return []model.Issue{newIssue("tf-gcp-pubsub-not-cmek", "LOW", path, line,
"Pub/Sub topic is not encrypted with a customer-managed key", resType+"."+resName)}
}
return nil
}
// Package model holds the core types shared across scan engines and reporters.
package model
import "strings"
// Ecosystem identifies a package ecosystem in OSV.dev terms.
type Ecosystem string
const (
EcosystemGo Ecosystem = "Go"
EcosystemNpm Ecosystem = "npm"
EcosystemPyPI Ecosystem = "PyPI"
EcosystemMaven Ecosystem = "Maven"
EcosystemPackagist Ecosystem = "Packagist"
EcosystemNuGet Ecosystem = "NuGet"
EcosystemPub Ecosystem = "Pub"
EcosystemCratesIO Ecosystem = "crates.io"
EcosystemRubyGems Ecosystem = "RubyGems"
EcosystemSwiftURL Ecosystem = "SwiftURL"
)
type Package struct {
Name string
Version string
Ecosystem Ecosystem
Source string // manifest file it was found in
Origin string
}
func (p Package) QueryName() string {
if p.Origin != "" {
return p.Origin
}
return p.Name
}
type Vulnerability struct {
ID string
Summary string
Severity string
CVSSVector string
FixedVersion string
Aliases []string
URL string
// KEV/KEVDateAdded are set by internal/kev when --kev is passed: KEV
// means this CVE is in CISA's Known Exploited Vulnerabilities catalog
// (confirmed real-world exploitation, not just a CVSS estimate).
// Annotation only -- doesn't affect the scan's exit code.
KEV bool `json:"kev,omitempty"`
KEVDateAdded string `json:"kevDateAdded,omitempty"`
}
type Finding struct {
Package Package
Vulns []Vulnerability
}
type Issue struct {
Scanner string
RuleID string
Title string
Severity string
File string
Line int
Match string
Message string
// CWEs are the applicable CWE IDs (e.g. "CWE-89"), most relevant first.
// A rule can legitimately map to more than one -- e.g. a hardcoded
// private key is both CWE-798 (hardcoded credential) and CWE-321
// (hardcoded crypto key) -- so this is a slice rather than one field.
CWEs []string `json:"cwes,omitempty"`
}
// CWEURL returns the canonical MITRE reference page for a "CWE-123" style ID.
func CWEURL(cwe string) string {
return "https://cwe.mitre.org/data/definitions/" + strings.TrimPrefix(cwe, "CWE-") + ".html"
}
package osv
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"github.com/colibrisec/ojo/internal/model"
)
const (
detailConcurrency = 10
maxBatchSize = 1000
)
var apiBase = "https://api.osv.dev/v1"
var httpClient = &http.Client{}
type batchQuery struct {
Package struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
} `json:"package"`
Version string `json:"version"`
}
type batchRequest struct {
Queries []batchQuery `json:"queries"`
}
type batchResultEntry struct {
Vulns []struct {
ID string `json:"id"`
} `json:"vulns"`
}
type batchResult struct {
Results []batchResultEntry `json:"results"`
}
func Scan(ctx context.Context, pkgs []model.Package) ([]model.Finding, error) {
if len(pkgs) == 0 {
return nil, nil
}
var results []batchResultEntry
for start := 0; start < len(pkgs); start += maxBatchSize {
end := min(start+maxBatchSize, len(pkgs))
chunk := pkgs[start:end]
req := batchRequest{Queries: make([]batchQuery, len(chunk))}
for i, p := range chunk {
req.Queries[i].Package.Name = p.QueryName()
req.Queries[i].Package.Ecosystem = string(p.Ecosystem)
req.Queries[i].Version = p.Version
}
var result batchResult
if err := post(ctx, apiBase+"/querybatch", req, &result); err != nil {
return nil, fmt.Errorf("osv querybatch: %w", err)
}
results = append(results, result.Results...)
}
idSet := map[string]struct{}{}
for _, r := range results {
for _, v := range r.Vulns {
idSet[v.ID] = struct{}{}
}
}
details := fetchDetails(ctx, idSet)
var findings []model.Finding
for i, r := range results {
if len(r.Vulns) == 0 {
continue
}
f := model.Finding{Package: pkgs[i]}
for _, v := range r.Vulns {
if d, ok := details[v.ID]; ok {
f.Vulns = append(f.Vulns, toVulnerability(d, pkgs[i]))
}
}
f.Vulns = dedupeVulns(f.Vulns)
findings = append(findings, f)
}
return findings, nil
}
type vulnDetail struct {
ID string `json:"id"`
Summary string `json:"summary"`
Details string `json:"details"`
Aliases []string `json:"aliases"`
Upstream []string `json:"upstream"`
Severity []struct {
Type string `json:"type"`
Score string `json:"score"`
} `json:"severity"`
DatabaseSpecific struct {
Severity string `json:"severity"`
} `json:"database_specific"`
References []struct {
URL string `json:"url"`
} `json:"references"`
Affected []struct {
Package struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
} `json:"package"`
Ranges []struct {
Events []struct {
Introduced string `json:"introduced"`
Fixed string `json:"fixed"`
} `json:"events"`
} `json:"ranges"`
} `json:"affected"`
}
func normalizeSeverity(s string) string {
if s == "MODERATE" {
return "MEDIUM"
}
return s
}
func preferredID(d vulnDetail) string {
for _, a := range append(d.Aliases, d.Upstream...) {
if strings.HasPrefix(a, "CVE-") {
return a
}
}
return d.ID
}
func summary(d vulnDetail) string {
if d.Summary != "" {
return d.Summary
}
const maxLen = 120
s := d.Details
if len(s) > maxLen {
s = strings.TrimSpace(s[:maxLen]) + "..."
}
return s
}
func toVulnerability(d vulnDetail, pkg model.Package) model.Vulnerability {
v := model.Vulnerability{ID: preferredID(d), Summary: summary(d), Aliases: d.Aliases, Severity: "UNKNOWN"}
if len(d.Severity) > 0 {
v.CVSSVector = d.Severity[0].Score
}
switch {
case d.DatabaseSpecific.Severity != "":
// GHSA-style human-reviewed label; prefer it when present.
v.Severity = normalizeSeverity(d.DatabaseSpecific.Severity)
default:
if label, ok := cvss3SeverityLabel(v.CVSSVector); ok {
v.Severity = label
}
}
if len(d.References) > 0 {
v.URL = d.References[0].URL
}
v.FixedVersion = resolveFixedVersion(d, pkg)
return v
}
func fetchDetails(ctx context.Context, ids map[string]struct{}) map[string]vulnDetail {
out := make(map[string]vulnDetail, len(ids))
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, detailConcurrency)
for id := range ids {
id := id
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
var d vulnDetail
if err := get(ctx, apiBase+"/vulns/"+id, &d); err != nil {
return
}
mu.Lock()
out[id] = d
mu.Unlock()
}()
}
wg.Wait()
return out
}
func post(ctx context.Context, url string, body, out any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
return do(req, out)
}
func get(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
return do(req, out)
}
func do(req *http.Request, out any) error {
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return json.NewDecoder(resp.Body).Decode(out)
}
package osv
import (
"math"
"strings"
)
func cvss3SeverityLabel(vector string) (string, bool) {
score, ok := cvss3BaseScore(vector)
if !ok {
return "", false
}
switch {
case score >= 9.0:
return "CRITICAL", true
case score >= 7.0:
return "HIGH", true
case score >= 4.0:
return "MEDIUM", true
case score > 0.0:
return "LOW", true
default:
return "NONE", true
}
}
var (
cvssAV = map[string]float64{"N": 0.85, "A": 0.62, "L": 0.55, "P": 0.2}
cvssAC = map[string]float64{"L": 0.77, "H": 0.44}
cvssUI = map[string]float64{"N": 0.85, "R": 0.62}
cvssCIA = map[string]float64{"H": 0.56, "L": 0.22, "N": 0}
cvssPRUnchanged = map[string]float64{"N": 0.85, "L": 0.62, "H": 0.27}
cvssPRChanged = map[string]float64{"N": 0.85, "L": 0.68, "H": 0.5}
)
// cvss3BaseScore implements the CVSS v3.1 base score formula (spec section 7.4).
func cvss3BaseScore(vector string) (float64, bool) {
if !strings.HasPrefix(vector, "CVSS:3.") {
return 0, false
}
metrics := map[string]string{}
for _, part := range strings.Split(vector, "/")[1:] {
k, v, ok := strings.Cut(part, ":")
if ok {
metrics[k] = v
}
}
av, ok := cvssAV[metrics["AV"]]
if !ok {
return 0, false
}
ac, ok := cvssAC[metrics["AC"]]
if !ok {
return 0, false
}
ui, ok := cvssUI[metrics["UI"]]
if !ok {
return 0, false
}
scopeChanged := metrics["S"] == "C"
prTable := cvssPRUnchanged
if scopeChanged {
prTable = cvssPRChanged
}
pr, ok := prTable[metrics["PR"]]
if !ok {
return 0, false
}
c, ok := cvssCIA[metrics["C"]]
if !ok {
return 0, false
}
i, ok := cvssCIA[metrics["I"]]
if !ok {
return 0, false
}
a, ok := cvssCIA[metrics["A"]]
if !ok {
return 0, false
}
iss := 1 - (1-c)*(1-i)*(1-a)
var impact float64
if scopeChanged {
impact = 7.52*(iss-0.029) - 3.25*math.Pow(iss-0.02, 15)
} else {
impact = 6.42 * iss
}
if impact <= 0 {
return 0, true
}
exploitability := 8.22 * av * ac * pr * ui
var base float64
if scopeChanged {
base = cvssRoundUp(math.Min(1.08*(impact+exploitability), 10))
} else {
base = cvssRoundUp(math.Min(impact+exploitability, 10))
}
return base, true
}
func cvssRoundUp(x float64) float64 {
intInput := int64(math.Round(x * 100000))
if intInput%10000 == 0 {
return float64(intInput) / 100000
}
return float64(intInput/10000+1) / 10
}
package osv
import (
"sort"
"github.com/colibrisec/ojo/internal/model"
)
func dedupeVulns(vulns []model.Vulnerability) []model.Vulnerability {
if len(vulns) < 2 {
return vulns
}
var groups [][]model.Vulnerability
for _, v := range vulns {
placed := false
for gi, g := range groups {
for _, m := range g {
if shareAlias(v, m) {
groups[gi] = append(groups[gi], v)
placed = true
break
}
}
if placed {
break
}
}
if !placed {
groups = append(groups, []model.Vulnerability{v})
}
}
merged := make([]model.Vulnerability, 0, len(groups))
for _, g := range groups {
merged = append(merged, mergeGroup(g))
}
return merged
}
func shareAlias(a, b model.Vulnerability) bool {
if a.ID == b.ID {
return true
}
ids := make(map[string]bool, 1+len(a.Aliases))
ids[a.ID] = true
for _, al := range a.Aliases {
ids[al] = true
}
if ids[b.ID] {
return true
}
for _, al := range b.Aliases {
if ids[al] {
return true
}
}
return false
}
func mergeGroup(g []model.Vulnerability) model.Vulnerability {
rep := g[0]
for _, v := range g[1:] {
if isMoreInformative(v, rep) {
rep = v
}
}
aliasSet := map[string]bool{}
for _, v := range g {
aliasSet[v.ID] = true
for _, a := range v.Aliases {
aliasSet[a] = true
}
}
delete(aliasSet, rep.ID)
merged := rep
merged.Aliases = make([]string, 0, len(aliasSet))
for a := range aliasSet {
merged.Aliases = append(merged.Aliases, a)
}
sort.Strings(merged.Aliases)
return merged
}
func isMoreInformative(a, b model.Vulnerability) bool {
aKnown, bKnown := a.Severity != "" && a.Severity != "UNKNOWN", b.Severity != "" && b.Severity != "UNKNOWN"
if aKnown != bKnown {
return aKnown
}
aFixed, bFixed := a.FixedVersion != "", b.FixedVersion != ""
if aFixed != bFixed {
return aFixed
}
return len(a.Summary) > len(b.Summary)
}
package osv
import (
"regexp"
"strconv"
"github.com/colibrisec/ojo/internal/model"
)
func resolveFixedVersion(d vulnDetail, pkg model.Package) string {
var best string
for _, aff := range d.Affected {
if aff.Package.Name != pkg.QueryName() || aff.Package.Ecosystem != string(pkg.Ecosystem) {
continue
}
for _, r := range aff.Ranges {
for _, ev := range r.Events {
if ev.Fixed == "" || versionCompare(ev.Fixed, pkg.Version) <= 0 {
continue // not actually newer than what's installed
}
if best == "" || versionCompare(ev.Fixed, best) < 0 {
best = ev.Fixed // closest fix version above the installed one
}
}
}
}
return best
}
var versionTokenRe = regexp.MustCompile(`\d+|\D+`)
func versionCompare(a, b string) int {
at := versionTokenRe.FindAllString(a, -1)
bt := versionTokenRe.FindAllString(b, -1)
for i := 0; i < len(at) || i < len(bt); i++ {
var ta, tb string
if i < len(at) {
ta = at[i]
}
if i < len(bt) {
tb = bt[i]
}
if ta == tb {
continue
}
na, aErr := strconv.Atoi(ta)
nb, bErr := strconv.Atoi(tb)
if aErr == nil && bErr == nil {
if na != nb {
return na - nb
}
continue
}
if ta < tb {
return -1
}
return 1
}
return 0
}
package quality
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
// Deliberately line-based, not token/AST-based: duplication detection
// doesn't need language-aware parsing (PMD CPD and jscpd both support
// plain lexical modes), and a line-based approach is language-agnostic for
// free — one algorithm for all six languages instead of six per-language
// token-stream extractors, for a "least effort, still correct" tradeoff
// specific to this metric.
const (
dupWindowLines = 6 // minimum block size considered, in significant (non-blank) lines
dupMinChars = 30 // minimum non-whitespace characters in a window — filters trivial matches like runs of "}"
)
var dupExtensions = map[string]bool{
".go": true,
".py": true,
".js": true, ".jsx": true, ".mjs": true, ".cjs": true,
".ts": true, ".mts": true, ".cts": true, ".tsx": true,
".php": true,
".rb": true,
".java": true,
}
type dupLine struct {
text string // trimmed
line int // 1-based original line number
}
type dupWindowRef struct {
file string
lines []dupLine // this file's full significant-line sequence (shared, not copied per window)
start int // index into lines
}
func scanDuplicates(root string) ([]model.Issue, error) {
files := map[string][]dupLine{}
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !dupExtensions[filepath.Ext(path)] {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var lines []dupLine
for i, raw := range strings.Split(string(data), "\n") {
t := strings.TrimSpace(raw)
if t == "" {
continue
}
lines = append(lines, dupLine{text: t, line: i + 1})
}
if len(lines) >= dupWindowLines {
files[path] = lines
}
return nil
})
if err != nil {
return nil, err
}
buckets := map[string][]dupWindowRef{}
for file, lines := range files {
for start := 0; start+dupWindowLines <= len(lines); start++ {
window := lines[start : start+dupWindowLines]
joined := joinDupLines(window)
if nonSpaceLen(joined) < dupMinChars {
continue
}
h := hashDupText(joined)
buckets[h] = append(buckets[h], dupWindowRef{file: file, lines: lines, start: start})
}
}
// Sorted bucket order for deterministic output — map iteration order
// would otherwise make which occurrence "wins" the overlap-suppression
// race (see reportedThrough below) vary run to run.
hashes := make([]string, 0, len(buckets))
for h := range buckets {
hashes = append(hashes, h)
}
sort.Strings(hashes)
reportedThrough := map[string]int{} // file -> index just past the last reported block, suppresses overlap spam from one long duplicate
var issues []model.Issue
for _, h := range hashes {
refs := buckets[h]
if len(refs) < 2 {
continue
}
var kept []dupWindowRef
for _, r := range refs {
if r.start < reportedThrough[r.file] {
continue
}
kept = append(kept, r)
}
if len(kept) < 2 {
continue
}
blockLen := dupWindowLines + extendMatch(kept)
locs := make([]string, len(kept))
for i, r := range kept {
locs[i] = fmt.Sprintf("%s:%d", r.file, r.lines[r.start].line)
}
for i, r := range kept {
others := append(append([]string{}, locs[:i]...), locs[i+1:]...)
endLine := r.lines[r.start+blockLen-1].line
issues = append(issues, newIssue("quality-duplicate-code", "MEDIUM", r.file, r.lines[r.start].line,
"Duplicate code block",
fmt.Sprintf("lines %d-%d duplicate %s", r.lines[r.start].line, endLine, strings.Join(others, ", "))))
reportedThrough[r.file] = r.start + blockLen
}
}
return issues, nil
}
// extendMatch returns how many additional lines (beyond the base window)
// every ref in refs keeps matching in lock-step — the minimum across all
// refs, so every occurrence reported for this group shares one common,
// fully-verified duplicate length.
func extendMatch(refs []dupWindowRef) int {
anchor := refs[0]
extra := 0
for {
next := dupWindowLines + extra
anchorIdx := anchor.start + next
if anchorIdx >= len(anchor.lines) {
return extra
}
want := anchor.lines[anchorIdx].text
for _, r := range refs[1:] {
idx := r.start + next
if idx >= len(r.lines) || r.lines[idx].text != want {
return extra
}
}
extra++
}
}
func joinDupLines(lines []dupLine) string {
parts := make([]string, len(lines))
for i, l := range lines {
parts[i] = l.text
}
return strings.Join(parts, "\n")
}
func nonSpaceLen(s string) int {
n := 0
for _, r := range s {
if r != ' ' && r != '\t' && r != '\n' {
n++
}
}
return n
}
func hashDupText(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
package quality
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"strings"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
func scanGo(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !strings.HasSuffix(path, ".go") {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
ast.Inspect(f, func(n ast.Node) bool {
switch fn := n.(type) {
case *ast.FuncDecl:
if fn.Body != nil {
issues = append(issues, measureGoFunc(fn.Name.Name, fn.Type.Params, fn.Body, fset, path).issues()...)
}
case *ast.FuncLit:
issues = append(issues, measureGoFunc("func literal", fn.Type.Params, fn.Body, fset, path).issues()...)
}
return true
})
return nil
})
return issues, err
}
func measureGoFunc(name string, params *ast.FieldList, body *ast.BlockStmt, fset *token.FileSet, path string) funcMetrics {
return funcMetrics{
name: name,
file: path,
startLine: fset.Position(body.Pos()).Line,
endLine: fset.Position(body.End()).Line,
params: goParamCount(params),
nesting: goNestingDepth(body),
complexity: goComplexity(body),
}
}
func goParamCount(params *ast.FieldList) int {
if params == nil {
return 0
}
n := 0
for _, f := range params.List {
if len(f.Names) == 0 {
n++ // unnamed parameter (e.g. interface method signature) is still one slot
continue
}
n += len(f.Names)
}
return n
}
// goComplexity is McCabe complexity: 1 + one per decision point anywhere in
// body, regardless of nesting (unlike goNestingDepth, complexity doesn't
// care how deep a branch is, only that it exists).
func goComplexity(body *ast.BlockStmt) int {
complexity := 1
ast.Inspect(body, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.IfStmt:
complexity++
case *ast.ForStmt:
complexity++
case *ast.RangeStmt:
complexity++
case *ast.CaseClause:
complexity++
case *ast.CommClause:
complexity++
case *ast.BinaryExpr:
if v.Op == token.LAND || v.Op == token.LOR {
complexity++
}
}
return true
})
return complexity
}
// goNestingDepth is the max depth of nested control-flow blocks
// (if/for/range/switch/select) within body — a different question from
// complexity: a function with 10 sibling ifs has high complexity but
// nesting depth 1; one if inside a for inside an if has nesting depth 3
// regardless of how many decision points that is in total.
func goNestingDepth(body *ast.BlockStmt) int {
max := 0
var walkNode func(n ast.Node, depth int)
walkNode = func(n ast.Node, depth int) {
if depth > max {
max = depth
}
ast.Inspect(n, func(child ast.Node) bool {
if child == n {
return true // don't re-enter the node walkNode was called with
}
switch child.(type) {
case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.SelectStmt:
walkNode(child, depth+1)
return false // walkNode's own Inspect call handles this subtree
}
return true
})
}
walkNode(body, 0)
return max
}
package quality
import (
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
var javaLang = grammars.JavaLanguage()
// javaSpec: Java's grammar wraps both "case X:" and "default:" in the same
// switch_label node type (unlike JS's distinct switch_case/switch_default)
// — caseLabelType handles that with a text-prefix check instead of a plain
// type-name match, verified directly against a real parse tree first.
var javaSpec = tsLangSpec{
funcTypes: map[string]bool{"method_declaration": true, "constructor_declaration": true, "lambda_expression": true},
branchTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "catch_clause": true, "ternary_expression": true},
nestTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "switch_expression": true, "try_statement": true},
binaryTypes: map[string]bool{"binary_expression": true},
logicalOps: map[string]bool{"&&": true, "||": true},
caseLabelType: "switch_label",
}
func scanJava(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !strings.HasSuffix(path, ".java") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tree, err := gts.NewParser(javaLang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, m := range tsMeasureFuncs(tree.RootNode(), javaLang, javaSpec, src, path) {
issues = append(issues, m.issues()...)
}
return nil
})
return issues, err
}
package quality
import (
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
var (
jsLang = grammars.JavascriptLanguage()
tsLang = grammars.TypescriptLanguage()
tsxLang = grammars.TsxLanguage()
)
// jsSpec is shared across all three grammars (js/ts/tsx) — the relevant
// node type names are identical across them, same fact internal/sast's
// mustTriQuery relies on.
var jsSpec = tsLangSpec{
funcTypes: map[string]bool{"function_declaration": true, "function_expression": true, "arrow_function": true, "method_definition": true, "generator_function_declaration": true, "generator_function": true},
branchTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "switch_case": true, "catch_clause": true, "ternary_expression": true},
nestTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "switch_statement": true, "try_statement": true},
binaryTypes: map[string]bool{"binary_expression": true},
logicalOps: map[string]bool{"&&": true, "||": true},
}
// jsLangForPath mirrors internal/sast's own extension-to-grammar mapping.
func jsLangForPath(path string) *gts.Language {
switch {
case strings.HasSuffix(path, ".tsx"):
return tsxLang
case strings.HasSuffix(path, ".ts"), strings.HasSuffix(path, ".mts"), strings.HasSuffix(path, ".cts"):
return tsLang
case strings.HasSuffix(path, ".js"), strings.HasSuffix(path, ".jsx"), strings.HasSuffix(path, ".mjs"), strings.HasSuffix(path, ".cjs"):
return jsLang
default:
return nil
}
}
func scanJS(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
lang := jsLangForPath(path)
if lang == nil {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tree, err := gts.NewParser(lang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, m := range tsMeasureFuncs(tree.RootNode(), lang, jsSpec, src, path) {
issues = append(issues, m.issues()...)
}
return nil
})
return issues, err
}
package quality
import (
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
var phpLang = grammars.PhpLanguage()
var phpSpec = tsLangSpec{
funcTypes: map[string]bool{"function_definition": true, "method_declaration": true, "anonymous_function": true, "arrow_function": true},
branchTypes: map[string]bool{"if_statement": true, "else_if_clause": true, "for_statement": true, "while_statement": true, "case_statement": true, "catch_clause": true, "conditional_expression": true},
nestTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "switch_statement": true, "try_statement": true},
binaryTypes: map[string]bool{"binary_expression": true},
logicalOps: map[string]bool{"&&": true, "||": true, "and": true, "or": true},
}
func scanPHP(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !strings.HasSuffix(path, ".php") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tree, err := gts.NewParser(phpLang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, m := range tsMeasureFuncs(tree.RootNode(), phpLang, phpSpec, src, path) {
issues = append(issues, m.issues()...)
}
return nil
})
return issues, err
}
package quality
import (
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
var pyLang = grammars.PythonLanguage()
// pySpec: Python's "and"/"or" have their own dedicated boolean_operator
// node type (distinct from comparison_operator and arithmetic
// binary_operator), so unlike the other four languages there's no
// operator-overloaded binaryTypes/logicalOps check needed at all.
var pySpec = tsLangSpec{
funcTypes: map[string]bool{"function_definition": true, "lambda": true},
branchTypes: map[string]bool{"if_statement": true, "elif_clause": true, "for_statement": true, "while_statement": true, "except_clause": true, "conditional_expression": true, "boolean_operator": true},
nestTypes: map[string]bool{"if_statement": true, "for_statement": true, "while_statement": true, "try_statement": true},
}
func scanPython(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !strings.HasSuffix(path, ".py") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tree, err := gts.NewParser(pyLang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, m := range tsMeasureFuncs(tree.RootNode(), pyLang, pySpec, src, path) {
issues = append(issues, m.issues()...)
}
return nil
})
return issues, err
}
// Package quality scans for maintainability smells — cyclomatic
// complexity, function length, nesting depth, parameter count,
// cross-file duplicate code, and tracked TODO/FIXME comments — across
// Go, Python, JS/TS, PHP, Ruby, and Java. These are code-quality
// findings, not security ones: a different problem shape from
// internal/sast, and deliberately independent of it — no shared
// taint/query infrastructure, just its own small per-language "what
// counts as a function" node-type sets. Off by default, enabled with
// --scanners quality.
package quality
import (
"fmt"
"github.com/colibrisec/ojo/internal/model"
)
const (
maxFunctionLines = 50
maxParameters = 5
maxNestingDepth = 4
maxComplexity = 10
)
// funcMetrics holds the four per-function measurements for one function/
// method/lambda/closure, regardless of source language.
type funcMetrics struct {
name string
file string
startLine int // 1-based
endLine int
params int
nesting int
complexity int // McCabe: 1 + decision points
}
func (m funcMetrics) issues() []model.Issue {
var issues []model.Issue
length := m.endLine - m.startLine + 1
if length > maxFunctionLines {
issues = append(issues, newIssue("quality-function-length", "LOW", m.file, m.startLine,
"Function too long",
fmt.Sprintf("%s is %d lines long (threshold: %d) — consider splitting it up", m.name, length, maxFunctionLines)))
}
if m.params > maxParameters {
issues = append(issues, newIssue("quality-parameter-count", "LOW", m.file, m.startLine,
"Too many parameters",
fmt.Sprintf("%s takes %d parameters (threshold: %d) — consider grouping related parameters into a struct/object", m.name, m.params, maxParameters)))
}
if m.nesting > maxNestingDepth {
issues = append(issues, newIssue("quality-nesting-depth", "LOW", m.file, m.startLine,
"Deeply nested code",
fmt.Sprintf("%s nests %d levels of control flow deep (threshold: %d) — consider extracting inner blocks into their own functions or using early returns", m.name, m.nesting, maxNestingDepth)))
}
if m.complexity > maxComplexity {
issues = append(issues, newIssue("quality-cyclomatic-complexity", "MEDIUM", m.file, m.startLine,
"High cyclomatic complexity",
fmt.Sprintf("%s has a cyclomatic complexity of %d (threshold: %d) — consider splitting it into smaller functions", m.name, m.complexity, maxComplexity)))
}
return issues
}
func newIssue(id, severity, file string, line int, title, message string) model.Issue {
return model.Issue{
Scanner: "quality",
RuleID: id,
Title: title,
Severity: severity,
File: file,
Line: line,
Message: message,
}
}
// Scan runs every quality metric — the four per-function AST metrics
// across all six languages, plus cross-file duplicate detection — against
// every source file under root.
func Scan(root string) ([]model.Issue, error) {
var issues []model.Issue
for _, scan := range []func(string) ([]model.Issue, error){
scanGo, scanPython, scanJS, scanPHP, scanRuby, scanJava,
} {
found, err := scan(root)
if err != nil {
return nil, err
}
issues = append(issues, found...)
}
dupIssues, err := scanDuplicates(root)
if err != nil {
return nil, err
}
issues = append(issues, dupIssues...)
todoIssues, err := scanTODOComments(root)
if err != nil {
return nil, err
}
issues = append(issues, todoIssues...)
return issues, nil
}
package quality
import (
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
var rubyLang = grammars.RubyLanguage()
// rubySpec: Ruby's grammar uses the bare keyword as the node type name for
// if/elsif/for/while/when/rescue/case/begin — verified directly, not
// assumed (same verification already relied on by internal/sast's Ruby
// rules). A method with an empty body has no body field at all (no
// body_statement node constructed) — tsMeasureFuncs/tsComplexity/
// tsNestingDepth already handle a nil body as the zero case.
var rubySpec = tsLangSpec{
funcTypes: map[string]bool{"method": true, "singleton_method": true, "lambda": true, "block": true},
branchTypes: map[string]bool{"if": true, "elsif": true, "for": true, "while": true, "when": true, "rescue": true, "conditional": true},
nestTypes: map[string]bool{"if": true, "for": true, "while": true, "case": true, "begin": true},
binaryTypes: map[string]bool{"binary": true},
logicalOps: map[string]bool{"&&": true, "||": true, "and": true, "or": true},
}
func scanRuby(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
if !strings.HasSuffix(path, ".rb") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
tree, err := gts.NewParser(rubyLang).Parse(src)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, m := range tsMeasureFuncs(tree.RootNode(), rubyLang, rubySpec, src, path) {
issues = append(issues, m.issues()...)
}
return nil
})
return issues, err
}
package quality
import (
"go/parser"
"go/token"
"io/fs"
"os"
"regexp"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
// SonarQube's own S1135 (TODO)/S1134 (FIXME) rules, generalized to the four
// markers real codebases actually use. Matched against real *comment* node
// text only (not a raw regex over the whole file), so a variable or string
// literal that happens to contain one of these words is never flagged —
// verified per language below, not assumed. Case-insensitive: "// todo:" is
// just as common in practice as "// TODO:". \b on both sides keeps
// "hackathon" from matching "hack".
var todoMarkerRe = regexp.MustCompile(`(?i)\b(TODO|FIXME|HACK|XXX)\b`)
// javaCommentTypes is the one language-specific wrinkle here: Java's
// grammar splits comments into two node types (line_comment/block_comment)
// where Python/JS/TS/TSX/PHP/Ruby all use a single "comment" type for both
// forms — confirmed by dumping a real parse tree for each before writing
// this, not assumed from the other five languages' shape.
var javaCommentTypes = map[string]bool{"line_comment": true, "block_comment": true}
func todoIssue(path string, line int, text string) model.Issue {
return newIssue("quality-todo-comment", "INFO", path, line,
"TODO/FIXME comment", "tracked comment: "+strings.TrimSpace(text))
}
// tsFindTODOs walks every node in a tree-sitter tree (including "extra"
// nodes — comments are extras in every one of these grammars) looking for
// a comment node whose text matches todoMarkerRe.
func tsFindTODOs(n *gts.Node, lang *gts.Language, src []byte, path string, isComment func(string) bool, issues *[]model.Issue) {
if isComment(n.Type(lang)) && todoMarkerRe.MatchString(string(n.Text(src))) {
*issues = append(*issues, todoIssue(path, int(n.StartPoint().Row)+1, string(n.Text(src))))
}
cc := n.ChildCount()
for i := 0; i < cc; i++ {
tsFindTODOs(n.Child(i), lang, src, path, isComment, issues)
}
}
func isPlainComment(t string) bool { return t == "comment" }
func isJavaComment(t string) bool { return javaCommentTypes[t] }
// scanTSFileTODOs reads and parses one file with the given grammar and
// appends any TODO/FIXME/HACK/XXX comment findings to *issues — the one
// read+parse+skip-on-error block shared by all five tree-sitter-backed
// languages below, instead of five copies of it.
func scanTSFileTODOs(path string, lang *gts.Language, isComment func(string) bool, issues *[]model.Issue) {
src, err := os.ReadFile(path)
if err != nil {
return
}
tree, err := gts.NewParser(lang).Parse(src)
if err != nil {
return
}
tsFindTODOs(tree.RootNode(), lang, src, path, isComment, issues)
}
func scanTODOComments(root string) ([]model.Issue, error) {
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
switch {
case strings.HasSuffix(path, ".go"):
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
for _, cg := range f.Comments {
for _, c := range cg.List {
if todoMarkerRe.MatchString(c.Text) {
issues = append(issues, todoIssue(path, fset.Position(c.Pos()).Line, c.Text))
}
}
}
case strings.HasSuffix(path, ".py"):
scanTSFileTODOs(path, pyLang, isPlainComment, &issues)
case strings.HasSuffix(path, ".php"):
scanTSFileTODOs(path, phpLang, isPlainComment, &issues)
case strings.HasSuffix(path, ".rb"):
scanTSFileTODOs(path, rubyLang, isPlainComment, &issues)
case strings.HasSuffix(path, ".java"):
scanTSFileTODOs(path, javaLang, isJavaComment, &issues)
default:
if lang := jsLangForPath(path); lang != nil {
scanTSFileTODOs(path, lang, isPlainComment, &issues)
}
}
return nil
})
return issues, err
}
package quality
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
)
// tsLangSpec bundles the per-language node-type facts needed to measure
// functions generically across all five tree-sitter-backed languages —
// one shared engine instead of five near-duplicate ones. Two facts,
// verified directly (not assumed) to be consistent across all five
// grammars before this was written as a single implementation:
//
// - every function-like node exposes its parameter list via a field
// literally named "parameters", and NamedChildCount() on that list —
// which tree-sitter's named/anonymous distinction already excludes
// punctuation tokens ("(", ",", ")") from — counts parameters
// correctly with no per-language parameter-node-type enumeration
// needed at all;
// - every function-like node exposes its body via a field named "body"
// (same fact taint_ts.go already relies on) — except a Ruby method
// with an empty body has no body field at all (no body_statement node
// gets constructed), handled by falling back to the function node's
// own span and treating a nil body as zero decision points/nesting.
type tsLangSpec struct {
funcTypes map[string]bool // node types that are functions/methods/lambdas/closures
branchTypes map[string]bool // node types that are +1 complexity unconditionally
nestTypes map[string]bool // node types that are +1 nesting depth (block-level control constructs)
binaryTypes map[string]bool // node types also used for arithmetic/comparison — need an operator-field check, can't be in branchTypes directly
logicalOps map[string]bool // operator field text values that make a binaryTypes node +1 complexity
caseLabelType string // node type (if any) that's shared between "case" and "default" labels and needs a text check to disambiguate (Java only)
}
func tsFuncName(n *gts.Node, lang *gts.Language, src []byte) string {
if name := n.ChildByFieldName("name", lang); name != nil {
return string(name.Text(src))
}
return "anonymous function"
}
func tsParamCount(n *gts.Node, lang *gts.Language) int {
params := n.ChildByFieldName("parameters", lang)
if params == nil {
return 0
}
return params.NamedChildCount()
}
// tsComplexity is McCabe complexity: 1 + one per decision point anywhere
// in body, regardless of nesting depth. body may be nil (an empty
// function body in a grammar that omits the body field entirely rather
// than producing an empty node) — treated as zero decision points.
func tsComplexity(body *gts.Node, lang *gts.Language, spec tsLangSpec, src []byte) int {
complexity := 1
if body == nil {
return complexity
}
var walkNode func(n *gts.Node)
walkNode = func(n *gts.Node) {
t := n.Type(lang)
switch {
case spec.branchTypes[t]:
complexity++
case spec.binaryTypes[t]:
if op := n.ChildByFieldName("operator", lang); op != nil && spec.logicalOps[string(op.Text(src))] {
complexity++
}
case spec.caseLabelType != "" && t == spec.caseLabelType:
if strings.HasPrefix(string(n.Text(src)), "case") {
complexity++
}
}
for _, c := range n.Children() {
walkNode(c)
}
}
walkNode(body)
return complexity
}
// tsNestingDepth is the max depth of nested block-level control-flow
// constructs (if/for/while/switch/try) within body — independent of
// complexity: ten sibling ifs is high complexity but nesting depth 1; one
// if inside a for inside an if is depth 3 regardless of total branch count.
func tsNestingDepth(body *gts.Node, lang *gts.Language, spec tsLangSpec) int {
if body == nil {
return 0
}
max := 0
var walkNode func(n *gts.Node, depth int)
walkNode = func(n *gts.Node, depth int) {
if depth > max {
max = depth
}
for _, c := range n.Children() {
if spec.nestTypes[c.Type(lang)] {
walkNode(c, depth+1)
} else {
walkNode(c, depth)
}
}
}
walkNode(body, 0)
return max
}
// tsMeasureFuncs walks root's whole tree finding every node matching
// spec.funcTypes and returns one funcMetrics per function found.
func tsMeasureFuncs(root *gts.Node, lang *gts.Language, spec tsLangSpec, src []byte, path string) []funcMetrics {
var out []funcMetrics
var walkNode func(n *gts.Node)
walkNode = func(n *gts.Node) {
if spec.funcTypes[n.Type(lang)] {
body := n.ChildByFieldName("body", lang)
start := n.StartPoint()
end := n.EndPoint()
out = append(out, funcMetrics{
name: tsFuncName(n, lang, src),
file: path,
startLine: int(start.Row) + 1,
endLine: int(end.Row) + 1,
params: tsParamCount(n, lang),
nesting: tsNestingDepth(body, lang, spec),
complexity: tsComplexity(body, lang, spec, src),
})
}
for _, c := range n.Children() {
walkNode(c)
}
}
walkNode(root)
return out
}
package report
import (
"fmt"
"io"
"path/filepath"
"strings"
)
type boxColumn struct {
Header string
Wrap int
}
func writeBoxTable(w io.Writer, cols []boxColumn, rows [][]string, colorCol int, color bool) {
n := len(cols)
widths := make([]int, n)
for i, c := range cols {
widths[i] = len([]rune(c.Header))
}
wrapped := make([][][]string, len(rows))
for ri, row := range rows {
wrapped[ri] = make([][]string, n)
for ci := range n {
text := ""
if ci < len(row) {
text = row[ci]
}
var lines []string
if cols[ci].Wrap > 0 {
lines = wrapText(text, cols[ci].Wrap)
} else {
lines = []string{text}
}
wrapped[ri][ci] = lines
for _, line := range lines {
if l := len([]rune(line)); l > widths[ci] {
widths[ci] = l
}
}
if cols[ci].Wrap > 0 && widths[ci] > cols[ci].Wrap {
widths[ci] = cols[ci].Wrap
}
}
}
headers := make([]string, n)
for i, c := range cols {
headers[i] = c.Header
}
fmt.Fprintln(w, borderLine(widths, "┌", "┬", "┐"))
fmt.Fprintln(w, dataLine(headers, widths, -1, false))
fmt.Fprintln(w, borderLine(widths, "├", "┼", "┤"))
for ri, row := range rows {
_ = row
height := 1
for ci := range n {
if len(wrapped[ri][ci]) > height {
height = len(wrapped[ri][ci])
}
}
for line := range height {
cells := make([]string, n)
for ci := range n {
if line < len(wrapped[ri][ci]) {
cells[ci] = wrapped[ri][ci][line]
}
}
fmt.Fprintln(w, dataLine(cells, widths, colorCol, color))
}
if ri < len(rows)-1 {
fmt.Fprintln(w, borderLine(widths, "├", "┼", "┤"))
}
}
fmt.Fprintln(w, borderLine(widths, "└", "┴", "┘"))
}
func borderLine(widths []int, left, mid, right string) string {
var sb strings.Builder
sb.WriteString(left)
for i, wd := range widths {
sb.WriteString(strings.Repeat("─", wd+2))
if i < len(widths)-1 {
sb.WriteString(mid)
} else {
sb.WriteString(right)
}
}
return sb.String()
}
func dataLine(cells []string, widths []int, colorCol int, color bool) string {
var sb strings.Builder
sb.WriteString("│")
for i, width := range widths {
text := ""
if i < len(cells) {
text = cells[i]
}
pad := max(width-len([]rune(text)), 0)
sb.WriteString(" ")
if color && i == colorCol && text != "" {
sb.WriteString(severityCode(text))
sb.WriteString(text)
sb.WriteString(ansiReset)
} else {
sb.WriteString(text)
}
sb.WriteString(strings.Repeat(" ", pad))
sb.WriteString(" │")
}
return sb.String()
}
func wrapText(s string, width int) []string {
if width <= 0 {
return []string{s}
}
var out []string
for para := range strings.SplitSeq(s, "\n") {
out = append(out, wrapParagraph(para, width)...)
}
if len(out) == 0 {
out = []string{""}
}
return out
}
func wrapParagraph(s string, width int) []string {
words := strings.Fields(s)
if len(words) == 0 {
return []string{""}
}
var lines []string
var cur strings.Builder
for _, word := range words {
for len(word) > width {
if cur.Len() > 0 {
lines = append(lines, cur.String())
cur.Reset()
}
lines = append(lines, word[:width])
word = word[width:]
}
switch {
case cur.Len() == 0:
cur.WriteString(word)
case cur.Len()+1+len(word) > width:
lines = append(lines, cur.String())
cur.Reset()
cur.WriteString(word)
default:
cur.WriteString(" ")
cur.WriteString(word)
}
}
if cur.Len() > 0 {
lines = append(lines, cur.String())
}
return lines
}
func mergeRuns(rows [][]string, cols []int) {
prev := make([]string, len(cols))
first := true
for _, row := range rows {
for ci, col := range cols {
raw := row[col]
if !first && raw == prev[ci] {
row[col] = ""
} else {
prev[ci] = raw
}
}
first = false
}
}
func relPath(root, path string) string {
if root == "" {
return path
}
rel, err := filepath.Rel(root, path)
if err != nil {
return path
}
return rel
}
package report
import (
"io"
"os"
"golang.org/x/term"
)
const (
ansiReset = "\x1b[0m"
ansiBoldRed = "\x1b[1;31m"
ansiRed = "\x1b[31m"
ansiYellow = "\x1b[33m"
ansiCyan = "\x1b[36m"
ansiGray = "\x1b[90m"
)
func isColorWriter(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
f, ok := w.(*os.File)
if !ok {
return false
}
return term.IsTerminal(int(f.Fd()))
}
func severityCode(sev string) string {
switch sev {
case "CRITICAL":
return ansiBoldRed
case "HIGH":
return ansiRed
case "MEDIUM", "MODERATE":
return ansiYellow
case "LOW":
return ansiCyan
default: // INFO, UNKNOWN
return ansiGray
}
}
func severityRank(sev string) int {
switch sev {
case "CRITICAL":
return 0
case "HIGH":
return 1
case "MEDIUM", "MODERATE":
return 2
case "LOW":
return 3
case "INFO":
return 4
default:
return 5
}
}
package report
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"path/filepath"
"time"
"github.com/colibrisec/ojo/internal/model"
)
// GitLab security report schemas: https://docs.gitlab.com/ee/user/application_security/#security-report-validation
// SAST and Secret Detection share one shape; Dependency Scanning has its own location/severity fields.
type glScanner struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version,omitempty"`
}
type glScan struct {
Scanner glScanner `json:"scanner"`
Analyzer glScanner `json:"analyzer"`
Type string `json:"type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Status string `json:"status"`
}
type glIdentifier struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
URL string `json:"url,omitempty"`
}
func gitlabScan(kind, version string) glScan {
now := time.Now().UTC().Format("2006-01-02T15:04:05")
scanner := glScanner{ID: "ojo", Name: "ojo", Version: version}
return glScan{Scanner: scanner, Analyzer: scanner, Type: kind, StartTime: now, EndTime: now, Status: "success"}
}
func gitlabSeverity(sev string) string {
switch sev {
case "CRITICAL":
return "Critical"
case "HIGH":
return "High"
case "MEDIUM", "MODERATE":
return "Medium"
case "LOW":
return "Low"
case "INFO":
return "Info"
default:
return "Unknown"
}
}
func fingerprint(parts ...string) string {
h := sha256.New()
for _, p := range parts {
io.WriteString(h, p)
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// --- SAST / Secret Detection (identical shape, different category) ---
type glIssueVuln struct {
ID string `json:"id"`
Category string `json:"category"`
Name string `json:"name"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
CVE string `json:"cve"`
Severity string `json:"severity"`
Scanner glScanner `json:"scanner"`
Location glIssueLoc `json:"location"`
Identifiers []glIdentifier `json:"identifiers"`
}
type glIssueLoc struct {
File string `json:"file"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
type glIssueReport struct {
Version string `json:"version"`
Vulnerabilities []glIssueVuln `json:"vulnerabilities"`
Scan glScan `json:"scan"`
}
func writeIssueReport(w io.Writer, root, category, schemaVersion, toolVersion string, issues []model.Issue) error {
vulns := make([]glIssueVuln, 0, len(issues))
for _, iss := range issues {
file := filepath.ToSlash(relPath(root, iss.File))
id := fingerprint(category, file, fmt.Sprint(iss.Line), iss.RuleID, iss.Message)
name := iss.Title
if name == "" {
name = iss.RuleID
}
idents := []glIdentifier{
{Type: "ojo_rule_id", Name: iss.RuleID, Value: iss.RuleID},
}
for _, cwe := range iss.CWEs {
idents = append(idents, glIdentifier{Type: "cwe", Name: cwe, Value: cwe, URL: model.CWEURL(cwe)})
}
vulns = append(vulns, glIssueVuln{
ID: id,
Category: category,
Name: name,
Message: iss.Message,
CVE: id,
Severity: gitlabSeverity(iss.Severity),
Scanner: glScanner{ID: "ojo", Name: "ojo"},
Location: glIssueLoc{File: file, StartLine: iss.Line, EndLine: iss.Line},
Identifiers: idents,
})
}
rep := glIssueReport{
Version: schemaVersion,
Vulnerabilities: vulns,
Scan: gitlabScan(category, toolVersion),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(rep)
}
// GitLabSAST writes a GitLab SAST report (gl-sast-report.json), covering both
// the sast and misconfig scanners since GitLab's own IaC analyzers report
// under the "sast" category too.
func (r Report) GitLabSAST(w io.Writer, root, toolVersion string) error {
var issues []model.Issue
for _, iss := range r.Issues {
if iss.Scanner == "sast" || iss.Scanner == "misconfig" {
issues = append(issues, iss)
}
}
return writeIssueReport(w, root, "sast", "15.0.7", toolVersion, issues)
}
// GitLabSecretDetection writes a GitLab Secret Detection report (gl-secret-detection-report.json).
func (r Report) GitLabSecretDetection(w io.Writer, root, toolVersion string) error {
var issues []model.Issue
for _, iss := range r.Issues {
if iss.Scanner == "secret" {
issues = append(issues, iss)
}
}
return writeIssueReport(w, root, "secret_detection", "15.0.7", toolVersion, issues)
}
// --- Dependency Scanning ---
type glDepVuln struct {
ID string `json:"id"`
Category string `json:"category"`
Name string `json:"name"`
Message string `json:"message"`
CVE string `json:"cve"`
Severity string `json:"severity"`
Solution string `json:"solution,omitempty"`
Scanner glScanner `json:"scanner"`
Location glDepLoc `json:"location"`
Identifiers []glIdentifier `json:"identifiers"`
}
type glDepLoc struct {
File string `json:"file"`
Dependency glDependency `json:"dependency"`
}
type glDependency struct {
Package glPackage `json:"package"`
Version string `json:"version"`
}
type glPackage struct {
Name string `json:"name"`
}
type glDepReport struct {
Version string `json:"version"`
Vulnerabilities []glDepVuln `json:"vulnerabilities"`
DependencyFiles []any `json:"dependency_files"`
Scan glScan `json:"scan"`
}
// GitLabDependencyScanning writes a GitLab Dependency Scanning report (gl-dependency-scanning-report.json).
func (r Report) GitLabDependencyScanning(w io.Writer, root, toolVersion string) error {
var vulns []glDepVuln
for _, f := range r.Findings {
file := filepath.ToSlash(relPath(root, f.Package.Source))
for _, v := range f.Vulns {
id := fingerprint("dependency_scanning", f.Package.Name, f.Package.Version, v.ID)
solution := ""
if v.FixedVersion != "" {
solution = "Upgrade to " + v.FixedVersion
}
idents := []glIdentifier{{Type: "vulnerability_id", Name: v.ID, Value: v.ID, URL: v.URL}}
for _, a := range v.Aliases {
idents = append(idents, glIdentifier{Type: "vulnerability_id", Name: a, Value: a})
}
vulns = append(vulns, glDepVuln{
ID: id,
Category: "dependency_scanning",
Name: v.Summary,
Message: v.Summary,
CVE: id,
Severity: gitlabSeverity(v.Severity),
Solution: solution,
Scanner: glScanner{ID: "ojo", Name: "ojo"},
Location: glDepLoc{
File: file,
Dependency: glDependency{Package: glPackage{Name: f.Package.Name}, Version: f.Package.Version},
},
Identifiers: idents,
})
}
}
rep := glDepReport{
Version: "15.0.6",
Vulnerabilities: vulns,
DependencyFiles: []any{},
Scan: gitlabScan("dependency_scanning", toolVersion),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(rep)
}
package report
import (
"encoding/json"
"fmt"
"io"
"sort"
"strings"
cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/colibrisec/ojo/internal/ignore"
"github.com/colibrisec/ojo/internal/model"
)
const titleWrapWidth = 60
func Table(w io.Writer, root string, findings []model.Finding) {
_ = root
if len(findings) == 0 {
fmt.Fprintln(w, "No vulnerabilities found.")
return
}
type vulnRow struct {
pkg model.Package
vuln model.Vulnerability
}
var flat []vulnRow
for _, f := range findings {
for _, v := range f.Vulns {
flat = append(flat, vulnRow{f.Package, v})
}
}
sort.SliceStable(flat, func(i, j int) bool {
if flat[i].pkg.Name != flat[j].pkg.Name {
return flat[i].pkg.Name < flat[j].pkg.Name
}
return severityRank(flat[i].vuln.Severity) < severityRank(flat[j].vuln.Severity)
})
rows := make([][]string, len(flat))
for i, r := range flat {
title := r.vuln.Summary
if r.vuln.URL != "" {
title += "\n" + r.vuln.URL
}
id := r.vuln.ID
if r.vuln.KEV {
id += "\n[KEV: exploited in the wild]"
}
rows[i] = []string{r.pkg.Name, id, r.vuln.Severity, "affected", r.pkg.Version, r.vuln.FixedVersion, title}
}
mergeRuns(rows, []int{0, 2, 3, 4})
cols := []boxColumn{
{Header: "Library"},
{Header: "Vulnerability"},
{Header: "Severity"},
{Header: "Status"},
{Header: "Installed Version"},
{Header: "Fixed Version"},
{Header: "Title", Wrap: titleWrapWidth},
}
writeBoxTable(w, cols, rows, 2, isColorWriter(w))
}
// JSON prints findings as indented JSON.
func JSON(w io.Writer, findings []model.Finding) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(findings)
}
func IssueTable(w io.Writer, root string, issues []model.Issue) {
if len(issues) == 0 {
return
}
sorted := make([]model.Issue, len(issues))
copy(sorted, issues)
sort.SliceStable(sorted, func(i, j int) bool {
return severityRank(sorted[i].Severity) < severityRank(sorted[j].Severity)
})
rows := make([][]string, len(sorted))
for i, iss := range sorted {
rule := iss.RuleID
if len(iss.CWEs) > 0 {
rule = fmt.Sprintf("%s (%s)", rule, strings.Join(iss.CWEs, ", "))
}
rows[i] = []string{
iss.Severity,
fmt.Sprintf("%s:%d", relPath(root, iss.File), iss.Line),
rule,
iss.Message,
}
}
mergeRuns(rows, []int{0}) // blank repeated Severity values, same as the vuln table
cols := []boxColumn{
{Header: "Severity"},
{Header: "Location"},
{Header: "Rule"},
{Header: "Message", Wrap: titleWrapWidth},
}
writeBoxTable(w, cols, rows, 0, isColorWriter(w))
}
type Report struct {
Target string `json:"target,omitempty"`
Findings []model.Finding `json:"findings,omitempty"`
Issues []model.Issue `json:"issues,omitempty"`
// Suppressed holds findings/issues matched by a .ojoignore rule, set by
// the caller after filtering Findings/Issues down to the kept set. Only
// the SARIF writer reads these (as native `suppressions`); every other
// format just omits suppressed results, so they're excluded from JSON.
SuppressedFindings []ignore.SuppressedFinding `json:"-"`
SuppressedIssues []ignore.SuppressedIssue `json:"-"`
}
func (r Report) JSON(w io.Writer) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(r)
}
func (r Report) Table(w io.Writer, root string) {
if len(r.Findings) == 0 && len(r.Issues) == 0 {
if r.Target != "" {
printTargetHeader(w, r.Target)
}
fmt.Fprintln(w, "No issues found.")
return
}
if r.Target != "" {
printTargetHeader(w, r.Target)
}
printTotalLine(w, r.Findings, r.Issues)
fmt.Fprintln(w)
if len(r.Findings) > 0 {
Table(w, root, r.Findings)
}
if len(r.Issues) > 0 {
if len(r.Findings) > 0 {
fmt.Fprintln(w)
}
IssueTable(w, root, r.Issues)
}
}
func printTargetHeader(w io.Writer, target string) {
fmt.Fprintln(w, target)
fmt.Fprintln(w, strings.Repeat("=", len([]rune(target))))
}
func printTotalLine(w io.Writer, findings []model.Finding, issues []model.Issue) {
counts := map[string]int{}
total := 0
for _, f := range findings {
for _, v := range f.Vulns {
counts[v.Severity]++
total++
}
}
for _, i := range issues {
counts[i.Severity]++
total++
}
color := isColorWriter(w)
order := []string{"UNKNOWN", "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL"}
var parts []string
for _, sev := range order {
n := counts[sev]
label := fmt.Sprintf("%s: %d", sev, n)
if color {
label = severityCode(sev) + label + ansiReset
}
parts = append(parts, label)
}
fmt.Fprintf(w, "Total: %d (%s)\n", total, strings.Join(parts, ", "))
}
// cyclonedxVersions maps a --cyclonedx-version flag value to the spec
// version it selects. "" (the flag's default) means latest.
var cyclonedxVersions = map[string]cdx.SpecVersion{
"": cdx.SpecVersion1_7,
"1.0": cdx.SpecVersion1_0,
"1.1": cdx.SpecVersion1_1,
"1.2": cdx.SpecVersion1_2,
"1.3": cdx.SpecVersion1_3,
"1.4": cdx.SpecVersion1_4,
"1.5": cdx.SpecVersion1_5,
"1.6": cdx.SpecVersion1_6,
"1.7": cdx.SpecVersion1_7,
}
// ParseCycloneDXVersion validates a --cyclonedx-version flag value. "" means
// latest. JSON output (the only format ojo writes) isn't supported below 1.2.
func ParseCycloneDXVersion(s string) (cdx.SpecVersion, error) {
v, ok := cyclonedxVersions[s]
if !ok {
return 0, fmt.Errorf("unsupported CycloneDX version %q (supported: 1.0-1.7)", s)
}
if v < cdx.SpecVersion1_2 {
return 0, fmt.Errorf("CycloneDX version %q: ojo's SBOM output is JSON, not supported below 1.2", s)
}
return v, nil
}
func SBOM(w io.Writer, pkgs []model.Package, version cdx.SpecVersion) error {
bom := cdx.NewBOM()
components := make([]cdx.Component, 0, len(pkgs))
for _, p := range pkgs {
components = append(components, cdx.Component{
Type: cdx.ComponentTypeLibrary,
Name: p.Name,
Version: p.Version,
PackageURL: Purl(p),
})
}
bom.Components = &components
enc := cdx.NewBOMEncoder(w, cdx.BOMFileFormatJSON)
enc.SetPretty(true)
return enc.EncodeVersion(bom, version)
}
// Purl returns a package-url (https://github.com/package-url/purl-spec)
// identifier for p. Used for SBOM component identity and (internal/vex) to
// match a finding's package against a VEX statement's product.
func Purl(p model.Package) string {
switch p.Ecosystem {
case model.EcosystemGo:
return fmt.Sprintf("pkg:golang/%s@%s", p.Name, p.Version)
case model.EcosystemNpm:
return fmt.Sprintf("pkg:npm/%s@%s", p.Name, p.Version)
case model.EcosystemPyPI:
return fmt.Sprintf("pkg:pypi/%s@%s", p.Name, p.Version)
default:
return fmt.Sprintf("pkg:generic/%s@%s", p.Name, p.Version)
}
}
package report
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"sort"
"github.com/colibrisec/ojo/internal/model"
)
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
InformationURI string `json:"informationUri,omitempty"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
ShortDescription sarifMessage `json:"shortDescription"`
HelpURI string `json:"helpUri,omitempty"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations"`
Suppressions []sarifSuppression `json:"suppressions,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
}
// kevProperties is the SARIF result.properties bag for a --kev-annotated
// finding -- CISA's confirmed-exploited signal, surfaced without changing
// Level (severity mapping stays CVSS-based; KEV is additive context, not a
// severity override).
func kevProperties(kevFlag bool, dateAdded string) map[string]any {
if !kevFlag {
return nil
}
return map[string]any{"kev": true, "kevDateAdded": dateAdded}
}
// sarifIssueRule builds the rules-table entry for a sast/misconfig/secret
// finding, pointing HelpURI at the primary (first) CWE when one is known.
func sarifIssueRule(iss model.Issue) sarifRule {
rule := sarifRule{ID: iss.RuleID, ShortDescription: sarifMessage{Text: iss.Title}}
if len(iss.CWEs) > 0 {
rule.HelpURI = model.CWEURL(iss.CWEs[0])
}
return rule
}
// cweProperties surfaces every applicable CWE on the result itself (not
// just the rule) so a finding with more than one CWE doesn't lose the rest
// to the rule table's single HelpURI.
func cweProperties(cwes []string) map[string]any {
if len(cwes) == 0 {
return nil
}
return map[string]any{"cwe": cwes}
}
type sarifSuppression struct {
Kind string `json:"kind"`
Justification string `json:"justification,omitempty"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
}
type sarifPhysicalLocation struct {
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
Region *sarifRegion `json:"region,omitempty"`
}
type sarifArtifactLocation struct {
URI string `json:"uri"`
}
type sarifRegion struct {
StartLine int `json:"startLine"`
}
type SARIFOptions struct {
OmitSuppressed bool
}
func (r Report) SARIF(w io.Writer, root string) error {
return r.SARIFWith(w, root, SARIFOptions{})
}
func (r Report) SARIFWith(w io.Writer, root string, opts SARIFOptions) error {
rules := map[string]sarifRule{}
results := []sarifResult{}
for _, f := range r.Findings {
for _, v := range f.Vulns {
if _, ok := rules[v.ID]; !ok {
rules[v.ID] = sarifRule{ID: v.ID, ShortDescription: sarifMessage{Text: v.Summary}, HelpURI: v.URL}
}
results = append(results, sarifResult{
RuleID: v.ID,
Level: sarifLevel(v.Severity),
Message: sarifMessage{Text: fmt.Sprintf("%s@%s: %s", f.Package.Name, f.Package.Version, v.Summary)},
Locations: []sarifLocation{{PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: sarifPath(root, f.Package.Source)},
}}},
Properties: kevProperties(v.KEV, v.KEVDateAdded),
})
}
}
for _, iss := range r.Issues {
if _, ok := rules[iss.RuleID]; !ok {
rules[iss.RuleID] = sarifIssueRule(iss)
}
var region *sarifRegion
if iss.Line > 0 {
region = &sarifRegion{StartLine: iss.Line}
}
results = append(results, sarifResult{
RuleID: iss.RuleID,
Level: sarifLevel(iss.Severity),
Message: sarifMessage{Text: iss.Message},
Properties: cweProperties(iss.CWEs),
Locations: []sarifLocation{{PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: sarifPath(root, iss.File)},
Region: region,
}}},
})
}
suppressedFindings, suppressedIssues := r.SuppressedFindings, r.SuppressedIssues
if opts.OmitSuppressed {
suppressedFindings, suppressedIssues = nil, nil
}
for _, sf := range suppressedFindings {
v := sf.Vuln
if _, ok := rules[v.ID]; !ok {
rules[v.ID] = sarifRule{ID: v.ID, ShortDescription: sarifMessage{Text: v.Summary}, HelpURI: v.URL}
}
results = append(results, sarifResult{
RuleID: v.ID,
Level: sarifLevel(v.Severity),
Message: sarifMessage{Text: fmt.Sprintf("%s@%s: %s", sf.Package.Name, sf.Package.Version, v.Summary)},
Locations: []sarifLocation{{PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: sarifPath(root, sf.Package.Source)},
}}},
Suppressions: []sarifSuppression{{Kind: "external", Justification: sf.Reason}},
Properties: kevProperties(v.KEV, v.KEVDateAdded),
})
}
for _, si := range suppressedIssues {
iss := si.Issue
if _, ok := rules[iss.RuleID]; !ok {
rules[iss.RuleID] = sarifIssueRule(iss)
}
var region *sarifRegion
if iss.Line > 0 {
region = &sarifRegion{StartLine: iss.Line}
}
results = append(results, sarifResult{
RuleID: iss.RuleID,
Level: sarifLevel(iss.Severity),
Message: sarifMessage{Text: iss.Message},
Properties: cweProperties(iss.CWEs),
Locations: []sarifLocation{{PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: sarifPath(root, iss.File)},
Region: region,
}}},
Suppressions: []sarifSuppression{{Kind: "external", Justification: si.Reason}},
})
}
ruleList := make([]sarifRule, 0, len(rules))
for _, rule := range rules {
ruleList = append(ruleList, rule)
}
sort.Slice(ruleList, func(i, j int) bool { return ruleList[i].ID < ruleList[j].ID })
log := sarifLog{
Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
Version: "2.1.0",
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{
Name: "ojo",
InformationURI: "https://colibrisec.dev/docs",
Rules: ruleList,
}},
Results: results,
}},
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(log)
}
func sarifLevel(severity string) string {
switch severity {
case "CRITICAL", "HIGH":
return "error"
case "MEDIUM", "MODERATE":
return "warning"
case "LOW", "INFO":
return "note"
default:
return "warning"
}
}
func sarifPath(root, path string) string {
return filepath.ToSlash(relPath(root, path))
}
package sast
import "strings"
// sastLangPrefixes are the per-language prefixes on every sast rule ID
// (go-sql-injection, java-sql-injection, js-sql-injection, ...). Rule IDs
// share the same category suffix across languages, so categoryCWEs is
// keyed by category instead of repeating each CWE six times over.
var sastLangPrefixes = []string{"go-", "java-", "js-", "php-", "py-", "ruby-"}
var categoryCWEs = map[string][]string{
"hardcoded-secret": {"CWE-798"},
"command-injection": {"CWE-78"},
"sql-injection": {"CWE-89"},
"nosqli": {"CWE-943"},
"weak-hash": {"CWE-328"},
"weak-cipher": {"CWE-327"},
"weak-cipher-des": {"CWE-327"},
"insecure-random-for-secrets": {"CWE-338"},
"predictable-prng-seed": {"CWE-336"},
"discarded-auth-error": {"CWE-252"},
"tls-insecure-skip-verify": {"CWE-295"},
"tls-verify-disabled": {"CWE-295"},
"tls-trust-manager-bypass": {"CWE-295"},
"permissive-file-mode": {"CWE-732"},
"open-redirect": {"CWE-601"},
"jwt-none-algorithm": {"CWE-347"},
"jwt-verify-disabled": {"CWE-347"},
"cors-wildcard": {"CWE-942"},
"insecure-cookie": {"CWE-614"},
"cookie-missing-flags": {"CWE-614"},
"path-traversal": {"CWE-22"},
"lfi-include": {"CWE-98"},
"ssrf": {"CWE-918"},
"ssti": {"CWE-1336"},
"empty-block": {"CWE-1071"},
"empty-exception-handler": {"CWE-390"},
"unreachable-code": {"CWE-561"},
"eval-detected": {"CWE-95"},
"eval-exec": {"CWE-95"},
"insecure-deserialization": {"CWE-502"},
"pickle-deserialization": {"CWE-502"},
"unsafe-reflection": {"CWE-470"},
"xxe": {"CWE-611"},
"yaml-unsafe-load": {"CWE-502"},
"dom-xss-innerhtml": {"CWE-79"},
"react-dangerously-set-innerhtml": {"CWE-79"},
"mass-assignment": {"CWE-915"},
"preg-replace-eval-modifier": {"CWE-95"},
"flask-debug-enabled": {"CWE-489"},
"jinja2-autoescape-disabled": {"CWE-79"},
"insecure-tempfile": {"CWE-377"},
"agent-unsandboxed-exec": {"CWE-94"},
}
// cweFor returns the CWE IDs for a sast rule ID by stripping its language
// prefix and looking up the shared category.
func cweFor(ruleID string) []string {
category := ruleID
for _, p := range sastLangPrefixes {
if rest, ok := strings.CutPrefix(ruleID, p); ok {
category = rest
break
}
}
return categoryCWEs[category]
}
package sast
import gts "github.com/odvcencio/gotreesitter"
// Same-file interprocedural taint tracking, extending taint_ts.go's
// intraprocedural engine one specific way: closing the "sink inside a
// helper function" false negative documented as this codebase's #1 taint
// ceiling. A tainted argument at a call site to a same-file, name-resolved
// function/unqualified-same-class method seeds that callee's matching
// parameter as an additional taint source for its own body — so a sink
// rule using that parameter directly (not the caller's request object)
// fires at its real location inside the callee, with zero changes to any
// of the ~130 existing sink-rule call sites (they all already go through
// tsTaintEnv, which now folds this seed in transparently).
//
// Deliberately NOT built: return-value taint propagation (helper(tainted)
// making the assigned variable tainted at the call site). Every exprTainted
// function in this package already treats *any* call expression containing
// a tainted argument as tainted overall, regardless of what the callee
// actually does with it (see e.g. TestGoTaintDoesNotCrossFunctionCalls) —
// so that direction is already covered, more broadly than a same-file
// call-graph could manage on its own (it works for calls to functions this
// file can't even see the body of). Building a narrower, registry-based
// version of it here would be strictly less capable, not an improvement.
//
// ponytail ceiling, same shape as everywhere else in this file: resolved
// by name only, not by type — free functions and unqualified same-class
// method calls, not qualified calls (obj.method(x)), since resolving which
// concrete type's method a qualified call targets needs real type
// resolution this project doesn't have. Fixed at 3 rounds, not a real
// fixpoint solver, mirroring tsTaintEnv's own 2-round bounded-iteration
// precedent — the seed only ever grows across rounds (monotonic), so a
// recursive or cyclic call chain just stops improving within the round
// budget instead of looping forever.
// interprocFuncInfo captures one same-file, name-resolved function's
// positional parameter names and body.
type interprocFuncInfo struct {
params []string
body *gts.Node
}
// tsCurrentParamSeed holds this file's precomputed interprocedural
// parameter taint seeds, keyed by function body node — set once per file
// by tsComputeParamSeed before that file's sink rules run, consulted
// transparently by tsTaintEnv. Shared across all five tree-sitter-backed
// languages: each language's own parse produces distinct node pointers,
// and only one file is scanned at a time.
//
// ponytail: file-scoped global, relies on each scanXFile running
// sequentially, not concurrently — would need a per-goroutine/per-call
// context instead if file scanning is ever parallelized.
var tsCurrentParamSeed = map[*gts.Node]map[string]bool{}
// identifierParamName handles Python/JS/Ruby/Java's parameter shapes: a
// plain identifier, or a wrapper (default/optional/splat/rest/typed
// parameter) with the identifier as a named child — verified against a
// real parse tree for every one of those wrapper shapes before writing
// this, not assumed from the simple case alone.
func identifierParamName(p *gts.Node, lang *gts.Language, src []byte) string {
if p.Type(lang) == "identifier" {
return string(p.Text(src))
}
for _, c := range p.Children() {
if c.Type(lang) == "identifier" {
return string(c.Text(src))
}
}
return ""
}
// phpParamName handles PHP's simple_parameter/variadic_parameter shape: a
// variable_name child whose own .Text() already includes the "$" sigil —
// the same key phpAssignInfo/phpExprTainted already use for a plain
// variable, verified against a real parse tree before writing this.
func phpParamName(p *gts.Node, lang *gts.Language, src []byte) string {
for _, c := range p.Children() {
if c.Type(lang) == "variable_name" {
return string(c.Text(src))
}
}
return ""
}
// tsParamNames extracts positional parameter names off a function/method
// definition node's "parameters" field (the same field name every
// tree-sitter grammar here uses, per internal/quality/treesitter.go's own
// verification of the same fact) via the language-specific paramName.
func tsParamNames(def *gts.Node, lang *gts.Language, src []byte, paramName func(*gts.Node, *gts.Language, []byte) string) []string {
params := def.ChildByFieldName("parameters", lang)
if params == nil {
return nil
}
var names []string
for _, p := range params.Children() {
if !p.IsNamed() {
continue
}
names = append(names, paramName(p, lang, src))
}
return names
}
// tsBuildFuncRegistry runs defQuery — the @fname/@def/@body query every
// language already has from its *-insecure-random-for-secrets rule — to
// build a same-file name -> (params, body) map for call-graph resolution.
func tsBuildFuncRegistry(root *gts.Node, lang *gts.Language, src []byte, defQuery *gts.Query, paramName func(*gts.Node, *gts.Language, []byte) string) map[string]interprocFuncInfo {
reg := map[string]interprocFuncInfo{}
for _, m := range defQuery.ExecuteNode(root, lang, src) {
var fname, def, body *gts.Node
for _, c := range m.Captures {
switch c.Name {
case "fname":
fname = c.Node
case "def":
def = c.Node
case "body":
body = c.Node
}
}
if fname == nil || def == nil || body == nil {
continue
}
reg[string(fname.Text(src))] = interprocFuncInfo{params: tsParamNames(def, lang, src, paramName), body: body}
}
return reg
}
// freeCall is one same-file call site resolved to a plain function name —
// not a qualified/method call (see tsFindFreeCalls).
type freeCall struct {
callee string
args *gts.Node
}
// tsFindFreeCalls runs callQuery over root and returns only the matches
// that are genuinely unqualified: callQuery captures an optional "recv"
// field precisely so a receiver-qualified call (Ruby/Java's "call"/
// "method_invocation" node types are shared between the two shapes) can be
// filtered out here rather than needing negation syntax in the query
// itself — verified directly that the optional-capture-then-nil-check
// technique correctly distinguishes `foo(x)` from `obj.foo(x)`/`this.foo(x)`
// before relying on it.
func tsFindFreeCalls(root *gts.Node, lang *gts.Language, src []byte, callQuery *gts.Query) []freeCall {
var calls []freeCall
for _, m := range callQuery.ExecuteNode(root, lang, src) {
var fn, args, recv *gts.Node
for _, c := range m.Captures {
switch c.Name {
case "fn":
fn = c.Node
case "args":
args = c.Node
case "recv":
recv = c.Node
}
}
if fn == nil || args == nil || recv != nil {
continue
}
calls = append(calls, freeCall{callee: string(fn.Text(src)), args: args})
}
return calls
}
// tsArgAt returns call.args's i-th positional argument expression, or nil
// if there aren't that many — unwrapping PHP's "argument" wrapper node
// (arguments (argument (expr))) the same way phpExprTainted's own call-case
// already does, since PHP is the one language here whose argument list
// doesn't expose the bare expression as the direct named child.
func tsArgAt(args *gts.Node, lang *gts.Language, i int) *gts.Node {
if i < 0 || i >= args.NamedChildCount() {
return nil
}
a := args.NamedChild(i)
if a.Type(lang) == "argument" && a.NamedChildCount() > 0 {
return a.NamedChild(0)
}
return a
}
// tsComputeParamSeed is the actual interprocedural pass: build the
// same-file call graph via tsBuildFuncRegistry/tsFindFreeCalls, then over a
// fixed number of rounds, seed each callee's parameter names with the
// argument positions some call site anywhere in the file passes tainted
// data to (using the calling function's own, possibly already-seeded,
// intraprocedural env). Called once per file by each scanXFile, before
// that file's rules run; the result is stashed in tsCurrentParamSeed for
// tsTaintEnv to consult transparently.
func tsComputeParamSeed(
root *gts.Node, lang *gts.Language, src []byte, boundary map[string]bool,
defQuery *gts.Query, callQuery *gts.Query, paramName func(*gts.Node, *gts.Language, []byte) string,
assignInfo func(*gts.Node, *gts.Language, []byte) (string, *gts.Node, bool),
exprTainted func(*gts.Node, *gts.Language, []byte, map[string]bool) bool,
) map[*gts.Node]map[string]bool {
reg := tsBuildFuncRegistry(root, lang, src, defQuery, paramName)
seed := map[*gts.Node]map[string]bool{}
for round := 0; round < 3; round++ {
for _, info := range reg {
env := tsTaintEnvWithSeed(info.body, lang, src, boundary, assignInfo, exprTainted, seed[info.body])
for _, call := range tsFindFreeCalls(info.body, lang, src, callQuery) {
callee, ok := reg[call.callee]
if !ok {
continue
}
for i, pname := range callee.params {
if pname == "" {
continue
}
arg := tsArgAt(call.args, lang, i)
if arg == nil || !exprTainted(arg, lang, src, env) {
continue
}
if seed[callee.body] == nil {
seed[callee.body] = map[string]bool{}
}
seed[callee.body][pname] = true
}
}
}
}
return seed
}
package sast
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
)
var javaLang = grammars.JavaLanguage()
func mustJavaQuery(src string) *gts.Query {
q, err := gts.NewQuery(src, javaLang)
if err != nil {
panic("sast: invalid java query: " + err.Error())
}
return q
}
type javaRule struct {
id string
severity string
check func(root *gts.Node, src []byte, path string) []model.Issue
}
var javaRules = []javaRule{
{"java-hardcoded-secret", "MEDIUM", checkJavaHardcodedSecret},
{"java-command-injection", "HIGH", checkJavaCommandInjection},
{"java-sql-injection", "HIGH", checkJavaSQLInjection},
{"java-weak-hash", "LOW", checkJavaWeakHash},
{"java-weak-cipher", "MEDIUM", checkJavaWeakCipher},
{"java-insecure-deserialization", "HIGH", checkJavaInsecureDeserialization},
{"java-insecure-random-for-secrets", "INFO", checkJavaInsecureRandom},
{"java-tls-trust-manager-bypass", "HIGH", checkJavaTLSTrustManagerBypass},
{"java-xxe", "HIGH", checkJavaXXE},
{"java-open-redirect", "MEDIUM", checkJavaOpenRedirect},
{"java-cors-wildcard", "MEDIUM", checkJavaCORSWildcard},
{"java-insecure-cookie", "MEDIUM", checkJavaInsecureCookie},
{"java-path-traversal", "HIGH", checkJavaPathTraversal},
{"java-cookie-missing-flags", "LOW", checkJavaCookieMissingFlags},
{"java-ssrf", "HIGH", checkJavaSSRF},
{"java-yaml-unsafe-load", "HIGH", checkJavaYAMLUnsafeLoad},
{"java-eval-detected", "HIGH", checkJavaEvalDetected},
{"java-unsafe-reflection", "HIGH", checkJavaUnsafeReflection},
{"java-predictable-prng-seed", "MEDIUM", checkJavaPredictablePRNGSeed},
{"java-jwt-none-algorithm", "HIGH", checkJavaJWTNoneAlgorithm},
{"java-empty-exception-handler", "MEDIUM", checkJavaEmptyExceptionHandler},
{"java-empty-block", "LOW", checkJavaEmptyBlock},
{"java-unreachable-code", "LOW", checkJavaUnreachableCode},
}
func javaIssueAt(id, severity, path, title, message string, n *gts.Node) model.Issue {
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: int(n.StartPoint().Row) + 1,
Message: message,
CWEs: cweFor(id),
}
}
func javaIsDynamicString(n *gts.Node, src []byte) bool {
if n.Type(javaLang) != "binary_expression" {
return false
}
op := n.ChildByFieldName("operator", javaLang)
return op != nil && string(op.Text(src)) == "+"
}
func trimJavaQuotes(s string) string {
if len(s) >= 2 {
return s[1 : len(s)-1]
}
return s
}
var javaSecretAssignQuery = mustJavaQuery(`(variable_declarator name: (identifier) @name value: (string_literal) @val) @decl`)
func checkJavaHardcodedSecret(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaSecretAssignQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
name := string(caps["name"].Text(src))
val := caps["val"]
if !nameLooksSecret(name) {
continue
}
if len(trimJavaQuotes(string(val.Text(src)))) <= 4 {
continue
}
issues = append(issues, javaIssueAt("java-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+name+" is assigned a literal string",
caps["decl"]))
}
return issues
}
var (
javaRuntimeExecQuery = mustJavaQuery(`(method_invocation object: (method_invocation object: (identifier) @cls name: (identifier) @m1) name: (identifier) @m2 arguments: (argument_list . (_) @arg) (#eq? @cls "Runtime") (#eq? @m1 "getRuntime") (#eq? @m2 "exec")) @call`)
javaProcessBuilderQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @cls arguments: (argument_list . (_) @arg) (#eq? @cls "ProcessBuilder")) @call`)
)
func checkJavaCommandInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaRuntimeExecQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if !javaIsDynamicString(caps["arg"], src) && !javaTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, javaIssueAt("java-command-injection", "HIGH", path,
"Command built from a non-literal argument",
"Runtime.getRuntime().exec(...) argument is built via `+` concatenation instead of a literal/argument array, or is a local variable derived from request/env input",
caps["call"]))
}
for _, m := range javaProcessBuilderQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if !javaIsDynamicString(caps["arg"], src) && !javaTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, javaIssueAt("java-command-injection", "HIGH", path,
"Command built from a non-literal argument",
"new ProcessBuilder(...) argument is built via `+` concatenation instead of a literal/argument array, or is a local variable derived from request/env input",
caps["call"]))
}
return issues
}
var javaStatementExecuteQuery = mustJavaQuery(`(method_invocation name: (identifier) @m arguments: (argument_list . (_) @arg) (#any-of? @m "execute" "executeQuery" "executeUpdate")) @call`)
func checkJavaSQLInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaStatementExecuteQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if !javaIsDynamicString(caps["arg"], src) && !javaTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, javaIssueAt("java-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
string(caps["m"].Text(src))+"(...) query argument is built via `+` concatenation instead of a PreparedStatement placeholder, or is a local variable derived from request/env input",
caps["call"]))
}
return issues
}
var javaMessageDigestQuery = mustJavaQuery(`(method_invocation object: (identifier) @cls name: (identifier) @m arguments: (argument_list (string_literal (string_fragment) @alg)) (#eq? @cls "MessageDigest") (#eq? @m "getInstance")) @call`)
func checkJavaWeakHash(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaMessageDigestQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
alg := string(caps["alg"].Text(src))
if alg != "MD5" && alg != "SHA1" && alg != "SHA-1" {
continue
}
issues = append(issues, javaIssueAt("java-weak-hash", "LOW", path,
"Weak hash algorithm", "MessageDigest.getInstance(\""+alg+"\") is cryptographically broken; use \"SHA-256\" or stronger",
caps["call"]))
}
return issues
}
var javaCipherQuery = mustJavaQuery(`(method_invocation object: (identifier) @cls name: (identifier) @m arguments: (argument_list (string_literal (string_fragment) @alg)) (#eq? @cls "Cipher") (#eq? @m "getInstance")) @call`)
func checkJavaWeakCipher(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaCipherQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
alg := string(caps["alg"].Text(src))
upper := strings.ToUpper(alg)
if !strings.Contains(upper, "DES") && !strings.Contains(upper, "RC4") && !strings.Contains(upper, "ECB") {
continue
}
issues = append(issues, javaIssueAt("java-weak-cipher", "MEDIUM", path,
"Weak cipher or insecure mode", "Cipher.getInstance(\""+alg+"\") uses a broken cipher or an insecure mode (ECB); use AES/GCM/NoPadding",
caps["call"]))
}
return issues
}
var javaReadObjectQuery = mustJavaQuery(`(method_invocation name: (identifier) @m (#eq? @m "readObject")) @call`)
func checkJavaInsecureDeserialization(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaReadObjectQuery.ExecuteNode(root, javaLang, src) {
issues = append(issues, javaIssueAt("java-insecure-deserialization", "HIGH", path,
"Insecure deserialization via readObject", "ObjectInputStream#readObject can instantiate arbitrary classes and execute code when given untrusted data",
javaCapMap(m)["call"]))
}
return issues
}
var (
javaRandomNewQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t (#eq? @t "Random")) @call`)
javaMethodDefQuery = mustJavaQuery(`(method_declaration name: (identifier) @fname body: (block) @body) @def`)
// javaFreeCallQuery captures an optional "recv" field precisely so a
// qualified call (obj.foo(x)/this.foo(x)) can be filtered out in Go
// code — Java's "method_invocation" node type is shared between
// unqualified same-class calls and qualified ones, verified directly
// before relying on this.
javaFreeCallQuery = mustJavaQuery(`(method_invocation object: (_)? @recv name: (identifier) @fn arguments: (argument_list) @args) @call`)
)
func checkJavaInsecureRandom(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaMethodDefQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
fname := string(caps["fname"].Text(src))
if !nameLooksSecret(fname) && !strings.Contains(strings.ToLower(fname), "session") {
continue
}
for _, rm := range javaRandomNewQuery.ExecuteNode(caps["body"], javaLang, src) {
issues = append(issues, javaIssueAt("java-insecure-random-for-secrets", "INFO", path,
"java.util.Random used in a security-sounding method",
"method "+fname+" uses java.util.Random, which is not cryptographically secure; consider java.security.SecureRandom",
javaCapMap(rm)["call"]))
}
}
return issues
}
var javaTrustManagerQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t (#any-of? @t "X509TrustManager" "HostnameVerifier")) @call`)
func checkJavaTLSTrustManagerBypass(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaTrustManagerQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
t := string(caps["t"].Text(src))
issues = append(issues, javaIssueAt("java-tls-trust-manager-bypass", "HIGH", path,
"Custom "+t+" implementation", "a custom "+t+" can silently disable TLS certificate/hostname validation; verify it doesn't just return true/accept everything",
caps["call"]))
}
return issues
}
var javaXXEFactoryQuery = mustJavaQuery(`(method_invocation object: (identifier) @cls name: (identifier) @m (#any-of? @cls "DocumentBuilderFactory" "SAXParserFactory" "XMLInputFactory") (#eq? @m "newInstance")) @call`)
func checkJavaXXE(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaXXEFactoryQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
cls := string(caps["cls"].Text(src))
issues = append(issues, javaIssueAt("java-xxe", "HIGH", path,
cls+" created without hardening", cls+".newInstance() is vulnerable to XXE by default unless external entity/DTD processing is explicitly disabled",
caps["call"]))
}
return issues
}
var javaFuncBoundary = map[string]bool{"method_declaration": true, "constructor_declaration": true, "lambda_expression": true}
func javaAssignInfo(n *gts.Node, lang *gts.Language, src []byte) (string, *gts.Node, bool) {
switch n.Type(javaLang) {
case "variable_declarator":
name := n.ChildByFieldName("name", javaLang)
val := n.ChildByFieldName("value", javaLang)
if name == nil || val == nil || name.Type(javaLang) != "identifier" {
return "", nil, false
}
return string(name.Text(src)), val, true
case "assignment_expression":
left := n.ChildByFieldName("left", javaLang)
right := n.ChildByFieldName("right", javaLang)
if left == nil || right == nil || left.Type(javaLang) != "identifier" {
return "", nil, false
}
return string(left.Text(src)), right, true
default:
return "", nil, false
}
}
// javaIsEnvSource matches System.getenv(...) by raw text.
func javaIsEnvSource(n *gts.Node, src []byte) bool {
return strings.HasPrefix(string(n.Text(src)), "System.getenv(")
}
// javaExprTainted reports whether n evaluates from tainted input: rooted
// at request/req (javaRootedAtRequest), an env-var read, a variable
// already known-tainted in env, or built from any of those via `+`
// concatenation or a method call's arguments.
func javaExprTainted(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
if javaRootedAtRequest(n, src) || javaIsEnvSource(n, src) {
return true
}
switch n.Type(javaLang) {
case "identifier":
return env[string(n.Text(src))]
case "binary_expression":
op := n.ChildByFieldName("operator", javaLang)
if op == nil || string(op.Text(src)) != "+" {
return false
}
return javaExprTainted(n.ChildByFieldName("left", javaLang), lang, src, env) || javaExprTainted(n.ChildByFieldName("right", javaLang), lang, src, env)
case "method_invocation":
args := n.ChildByFieldName("arguments", javaLang)
if args == nil {
return false
}
for _, a := range args.Children() {
if javaExprTainted(a, lang, src, env) {
return true
}
}
return false
default:
return false
}
}
// javaTaintedArg reports whether arg evaluates from tainted input, tracking
// through local variable assignments within its enclosing method/
// constructor/lambda (intraprocedural taint tracking — see taint_ts.go).
func javaTaintedArg(arg *gts.Node, src []byte) bool {
body := tsEnclosingBody(arg, javaLang, javaFuncBoundary)
env := tsTaintEnv(body, javaLang, src, javaFuncBoundary, javaAssignInfo, javaExprTainted)
return javaExprTainted(arg, javaLang, src, env)
}
func javaRootedAtRequest(n *gts.Node, src []byte) bool {
for {
switch n.Type(javaLang) {
case "method_invocation", "field_access":
obj := n.ChildByFieldName("object", javaLang)
if obj == nil {
return false
}
n = obj
case "identifier":
name := strings.ToLower(string(n.Text(src)))
return name == "request" || name == "req"
default:
return false
}
}
}
var javaSendRedirectQuery = mustJavaQuery(`(method_invocation name: (identifier) @m arguments: (argument_list . (_) @arg) (#eq? @m "sendRedirect")) @call`)
func checkJavaOpenRedirect(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaSendRedirectQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
arg := caps["arg"]
if !javaIsDynamicString(arg, src) && !javaTaintedArg(arg, src) {
continue
}
issues = append(issues, javaIssueAt("java-open-redirect", "MEDIUM", path,
"Redirect target built from request data",
"sendRedirect(...) argument is derived from request input (directly, or through a local variable) or built via `+` concatenation rather than a literal/allowlisted URL",
caps["call"]))
}
return issues
}
var javaHeaderCallQuery = mustJavaQuery(`(method_invocation name: (identifier) @m arguments: (argument_list . (string_literal) @key . (string_literal) @val) (#any-of? @m "setHeader" "addHeader")) @call`)
func checkJavaCORSWildcard(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaHeaderCallQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
key := trimJavaQuotes(string(caps["key"].Text(src)))
val := trimJavaQuotes(string(caps["val"].Text(src)))
if !strings.EqualFold(key, "Access-Control-Allow-Origin") || val != "*" {
continue
}
issues = append(issues, javaIssueAt("java-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard", `setHeader("Access-Control-Allow-Origin", "*") allows any origin to make credentialed cross-origin requests`,
caps["call"]))
}
return issues
}
var javaCookieBoolCallQuery = mustJavaQuery(`(method_invocation name: (identifier) @m arguments: (argument_list (false)) (#any-of? @m "setSecure" "setHttpOnly")) @call`)
func checkJavaInsecureCookie(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaCookieBoolCallQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
issues = append(issues, javaIssueAt("java-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", string(caps["m"].Text(src))+"(false) weakens cookie protection",
caps["call"]))
}
return issues
}
var javaScriptEvalQuery = mustJavaQuery(`(method_invocation name: (identifier) @m arguments: (argument_list . (_) @arg) (#eq? @m "eval")) @call`)
// checkJavaEvalDetected flags a `.eval(...)` call (matched by method name
// only, not a verified javax.script.ScriptEngine/JEXL/MVEL receiver — no
// type resolution available) whose argument is dynamic or tainted.
// Unlike Python's eval()/exec() (unconditionally flagged: essentially no
// legitimate call ever has attacker-reachable input and even literal use is
// rare), script-engine eval() with a literal/hardcoded script is a normal,
// common pattern (loading a bundled rule-engine script, etc.), so this rule
// is gated on the argument being non-literal instead of unconditional.
func checkJavaEvalDetected(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaScriptEvalQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
arg := caps["arg"]
if !javaIsDynamicString(arg, src) && !javaTaintedArg(arg, src) {
continue
}
issues = append(issues, javaIssueAt("java-eval-detected", "HIGH", path,
"Script engine eval() with a non-literal argument",
".eval(...) argument is built via `+` concatenation, or is a local variable derived from request/env input — evaluating untrusted input as script code (javax.script.ScriptEngine or similar) is remote code execution",
caps["call"]))
}
return issues
}
var javaClassForNameQuery = mustJavaQuery(`(method_invocation object: (identifier) @cls name: (identifier) @m arguments: (argument_list . (_) @arg) (#eq? @cls "Class") (#eq? @m "forName")) @call`)
// checkJavaUnsafeReflection flags Class.forName(...) when the class-name
// argument is itself tainted (request/env-derived, directly or through a
// local variable) — not gated on javaIsDynamicString: Class.forName is
// routinely called with a plain (non-literal, non-tainted) variable in
// normal code (e.g. loading a JDBC driver class name from a properties
// file), so only the taint check is used, same reasoning as
// ruby-unsafe-reflection/php-unsafe-reflection.
func checkJavaUnsafeReflection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaClassForNameQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
arg := caps["arg"]
if !javaTaintedArg(arg, src) {
continue
}
issues = append(issues, javaIssueAt("java-unsafe-reflection", "HIGH", path,
"Class loaded by an attacker-controlled name",
"Class.forName(...) argument is derived from request/env input (directly, or through a local variable) — this loads/initializes whatever class name an attacker supplies",
caps["call"]))
}
return issues
}
var javaRandomSeedQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t arguments: (argument_list . (decimal_integer_literal)) (#eq? @t "Random")) @call`)
// checkJavaPredictablePRNGSeed flags new Random(<literal>) — a fixed seed
// makes every subsequent value fully predictable (distinct from
// java-insecure-random-for-secrets, which flags new Random() used in a
// security-sounding method, not the seed). new Random() with no args
// (seeded from system entropy) is unaffected.
func checkJavaPredictablePRNGSeed(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaRandomSeedQuery.ExecuteNode(root, javaLang, src) {
issues = append(issues, javaIssueAt("java-predictable-prng-seed", "MEDIUM", path,
"PRNG seeded with a hardcoded literal",
"new Random(...) is called with a compile-time integer literal; every run produces the same sequence, making all subsequent output predictable",
javaCapMap(m)["call"]))
}
return issues
}
var javaYamlLoadQuery = mustJavaQuery(`(method_invocation object: (object_creation_expression type: (type_identifier) @t) @newExpr name: (identifier) @m arguments: (argument_list . (_) @arg) (#eq? @t "Yaml") (#eq? @m "load")) @call`)
// checkJavaYAMLUnsafeLoad flags SnakeYAML's default (zero-argument)
// Yaml().load(...), which uses an unsafe Constructor that can instantiate
// arbitrary Java classes from untrusted YAML — the source of multiple
// public RCE CVEs (e.g. CVE-2022-1471). `new Yaml(anything).load(...)` is
// not flagged: a non-default constructor argument is at minimum an
// explicit choice (SafeConstructor or otherwise) rather than the silent
// unsafe default, so requiring zero constructor args keeps this rule from
// false-positiving on the hardened form. Matches the direct
// `new Yaml(...).load(...)` chain only, not `Yaml y = new Yaml(); y.load(...)`
// split across two statements — same "flag the candidate" tradeoff as
// java-xxe/java-tls-trust-manager-bypass otherwise.
func checkJavaYAMLUnsafeLoad(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaYamlLoadQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if args := caps["newExpr"].ChildByFieldName("arguments", javaLang); args != nil && args.NamedChildCount() > 0 {
continue
}
issues = append(issues, javaIssueAt("java-yaml-unsafe-load", "HIGH", path,
"SnakeYAML unsafe deserialization", "new Yaml().load(...) uses SnakeYAML's default Constructor, which can instantiate arbitrary Java classes from untrusted YAML; use new Yaml(new SafeConstructor()) instead",
caps["call"]))
}
return issues
}
var javaURLNewQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t arguments: (argument_list . (_) @arg) (#eq? @t "URL")) @call`)
func checkJavaSSRF(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaURLNewQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
arg := caps["arg"]
if !javaIsDynamicString(arg, src) && !javaTaintedArg(arg, src) {
continue
}
issues = append(issues, javaIssueAt("java-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"new URL(...) argument is derived from request input (directly, or through a local variable) or built via `+` concatenation rather than a validated/allowlisted URL",
caps["call"]))
}
return issues
}
var javaFileNewQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t arguments: (argument_list . (_) @arg) (#any-of? @t "File" "FileInputStream" "FileReader")) @call`)
func checkJavaPathTraversal(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaFileNewQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
arg := caps["arg"]
if !javaIsDynamicString(arg, src) && !javaTaintedArg(arg, src) {
continue
}
t := string(caps["t"].Text(src))
issues = append(issues, javaIssueAt("java-path-traversal", "HIGH", path,
"File path built from request data", "new "+t+"(...) path is derived from request input (directly, or through a local variable) or built via `+` concatenation rather than a validated literal; sanitize/allowlist before use",
caps["call"]))
}
return issues
}
var (
javaCookieNewQuery = mustJavaQuery(`(object_creation_expression type: (type_identifier) @t (#eq? @t "Cookie")) @call`)
javaCookieSetterQuery = mustJavaQuery(`(method_invocation name: (identifier) @m (#any-of? @m "setSecure" "setHttpOnly")) @call`)
)
// checkJavaCookieMissingFlags is a same-method-body co-occurrence check, not
// real data-flow: it flags a `new Cookie(...)` when setSecure/setHttpOnly
// don't appear anywhere else as a call in the same method body. It can't
// tell which Cookie variable a given setter call was hardening when a method
// juggles more than one — same "flag the candidate, let a human confirm"
// tradeoff as java-tls-trust-manager-bypass/java-xxe.
func checkJavaCookieMissingFlags(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaMethodDefQuery.ExecuteNode(root, javaLang, src) {
body := javaCapMap(m)["body"]
newCookies := javaCookieNewQuery.ExecuteNode(body, javaLang, src)
if len(newCookies) == 0 {
continue
}
setters := map[string]bool{}
for _, sm := range javaCookieSetterQuery.ExecuteNode(body, javaLang, src) {
setters[string(javaCapMap(sm)["m"].Text(src))] = true
}
for _, cm := range newCookies {
call := javaCapMap(cm)["call"]
for _, flag := range []string{"setSecure", "setHttpOnly"} {
if setters[flag] {
continue
}
issues = append(issues, javaIssueAt("java-cookie-missing-flags", "LOW", path,
flag+" never called on cookie", "new Cookie(...) is created in this method but "+flag+"(true) is never called; it defaults to false, weakening cookie protection unless set elsewhere",
call))
}
}
}
return issues
}
// javaJWTNoneMethodQuery matches auth0 java-jwt's Algorithm.none(), which
// constructs an unsecured-JWT signer/verifier directly.
// javaJWTNoneFieldQuery matches jjwt's SignatureAlgorithm.NONE constant,
// used the same way Go's jwt.SigningMethodNone is: passed wherever the
// library expects a signing algorithm, accepting unsigned tokens.
var (
javaJWTNoneMethodQuery = mustJavaQuery(`(method_invocation object: (identifier) @cls name: (identifier) @m (#eq? @cls "Algorithm") (#eq? @m "none")) @call`)
javaJWTNoneFieldQuery = mustJavaQuery(`(field_access object: (identifier) @cls field: (identifier) @f (#eq? @cls "SignatureAlgorithm") (#eq? @f "NONE")) @call`)
)
func checkJavaJWTNoneAlgorithm(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaJWTNoneMethodQuery.ExecuteNode(root, javaLang, src) {
issues = append(issues, javaIssueAt("java-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "Algorithm.none() accepts unsigned tokens, allowing signature bypass",
javaCapMap(m)["call"]))
}
for _, m := range javaJWTNoneFieldQuery.ExecuteNode(root, javaLang, src) {
issues = append(issues, javaIssueAt("java-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "SignatureAlgorithm.NONE accepts unsigned tokens, allowing signature bypass",
javaCapMap(m)["call"]))
}
return issues
}
// checkJavaEmptyExceptionHandler is ojo's first reliability ("Bug", not
// "Vulnerability") rule for Java: an empty catch block silently swallows
// whatever exception it caught, hiding real failures — SonarQube's S2486/
// S1166 in its own rule set. A catch block that does anything at all
// (logging, rethrowing, a comment doesn't count since it isn't a node) is
// not flagged.
var javaCatchQuery = mustJavaQuery(`(catch_clause body: (block) @body) @catch`)
func checkJavaEmptyExceptionHandler(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaCatchQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, javaIssueAt("java-empty-exception-handler", "MEDIUM", path,
"Empty catch block", "catch (...) { } silently swallows the exception, hiding real failures; at minimum log it",
caps["catch"]))
}
return issues
}
// checkJavaEmptyBlock flags an if/else/while/for body with no statements at
// all (SonarQube's S108) — almost always dead code, or (in the if-branch
// case) a silently-swallowed condition. An empty method/class body is not
// flagged: unlike a branch, that's an ordinary stub/interface implementation.
var (
javaIfBodyQuery = mustJavaQuery(`(if_statement consequence: (block) @body) @stmt`)
javaElseBodyQuery = mustJavaQuery(`(if_statement alternative: (block) @body) @stmt`)
javaWhileQuery = mustJavaQuery(`(while_statement body: (block) @body) @stmt`)
javaForBodyQuery = mustJavaQuery(`(for_statement body: (block) @body) @stmt`)
)
func checkJavaEmptyBlock(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for shape, q := range map[string]*gts.Query{
"if": javaIfBodyQuery, "else": javaElseBodyQuery, "while": javaWhileQuery, "for": javaForBodyQuery,
} {
for _, m := range q.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, javaIssueAt("java-empty-block", "LOW", path,
"Empty "+shape+" block", shape+" body has no statements — likely dead code, or (if this is an error check) a silently-swallowed condition",
caps["stmt"]))
}
}
return issues
}
// checkJavaUnreachableCode flags a statement immediately following a
// return/throw/break/continue in the same block — SonarQube's S1763. Flags
// only the first unreachable statement per terminal statement, not every
// statement after it, to avoid spamming one issue per line of genuinely
// dead code.
var javaUnreachableQuery = mustJavaQuery(`(block [(return_statement) (throw_statement) (break_statement) (continue_statement)] @term . (_) @after)`)
func checkJavaUnreachableCode(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range javaUnreachableQuery.ExecuteNode(root, javaLang, src) {
caps := javaCapMap(m)
issues = append(issues, javaIssueAt("java-unreachable-code", "LOW", path,
"Unreachable code", "this statement can never execute; it follows a "+string(caps["term"].Text(src))+" in the same block",
caps["after"]))
}
return issues
}
func javaCapMap(m gts.QueryMatch) map[string]*gts.Node {
out := make(map[string]*gts.Node, len(m.Captures))
for _, c := range m.Captures {
out[c.Name] = c.Node
}
return out
}
package sast
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
)
var (
jsLang = grammars.JavascriptLanguage()
tsLang = grammars.TypescriptLanguage()
tsxLang = grammars.TsxLanguage()
)
type triQuery struct {
js, ts, tsx *gts.Query
}
func (q triQuery) forLang(lang *gts.Language) *gts.Query {
switch lang {
case jsLang:
return q.js
case tsLang:
return q.ts
case tsxLang:
return q.tsx
default:
return nil
}
}
func mustQueryFor(lang *gts.Language, src string) *gts.Query {
q, err := gts.NewQuery(src, lang)
if err != nil {
panic("sast: invalid js/ts query: " + err.Error())
}
return q
}
func mustTriQuery(src string) triQuery {
return triQuery{js: mustQueryFor(jsLang, src), ts: mustQueryFor(tsLang, src), tsx: mustQueryFor(tsxLang, src)}
}
func mustJSXQuery(src string) triQuery {
return triQuery{js: mustQueryFor(jsLang, src), tsx: mustQueryFor(tsxLang, src)}
}
type jsRule struct {
id string
severity string
check func(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue
}
var jsRules = []jsRule{
{"js-hardcoded-secret", "MEDIUM", checkJSHardcodedSecret},
{"js-eval-detected", "HIGH", checkJSEvalDetected},
{"js-command-injection", "HIGH", checkJSCommandInjection},
{"js-sql-injection", "HIGH", checkJSSQLInjection},
{"js-weak-hash", "LOW", checkJSWeakHash},
{"js-weak-cipher", "MEDIUM", checkJSWeakCipher},
{"js-insecure-random-for-secrets", "INFO", checkJSInsecureRandom},
{"js-tls-verify-disabled", "HIGH", checkJSTLSVerifyDisabled},
{"js-dom-xss-innerhtml", "MEDIUM", checkJSDOMXSSInnerHTML},
{"js-react-dangerously-set-innerhtml", "MEDIUM", checkJSReactDangerouslySetInnerHTML},
{"js-open-redirect", "MEDIUM", checkJSOpenRedirect},
{"js-jwt-none-algorithm", "HIGH", checkJSJWTNoneAlgorithm},
{"js-yaml-unsafe-load", "MEDIUM", checkJSYAMLUnsafeLoad},
{"js-cors-wildcard", "MEDIUM", checkJSCORSWildcard},
{"js-insecure-cookie", "MEDIUM", checkJSInsecureCookie},
{"js-path-traversal", "HIGH", checkJSPathTraversal},
{"js-cookie-missing-flags", "LOW", checkJSCookieMissingFlags},
{"js-ssrf", "HIGH", checkJSSSRF},
{"js-ssti", "HIGH", checkJSSSTI},
{"js-nosqli", "HIGH", checkJSNoSQLi},
{"js-unsafe-reflection", "HIGH", checkJSUnsafeReflection},
{"js-empty-exception-handler", "MEDIUM", checkJSEmptyExceptionHandler},
{"js-empty-block", "LOW", checkJSEmptyBlock},
{"js-unreachable-code", "LOW", checkJSUnreachableCode},
}
func jsIssueAt(id, severity, path, title, message string, n *gts.Node) model.Issue {
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: int(n.StartPoint().Row) + 1,
Message: message,
CWEs: cweFor(id),
}
}
func jsIsDynamicString(n *gts.Node, lang *gts.Language, src []byte) bool {
switch n.Type(lang) {
case "template_string":
for _, c := range n.Children() {
if c.Type(lang) == "template_substitution" {
return true
}
}
return false
case "binary_expression":
op := n.ChildByFieldName("operator", lang)
return op != nil && string(op.Text(src)) == "+"
default:
return false
}
}
var secretDeclQuery = mustTriQuery(`(variable_declarator name: (identifier) @name value: (string) @val) @decl`)
func checkJSHardcodedSecret(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range secretDeclQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
name := string(caps["name"].Text(src))
val := caps["val"]
if !nameLooksSecret(name) || len(trimQuotes(string(val.Text(src)))) <= 4 {
continue
}
issues = append(issues, jsIssueAt("js-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+name+" is assigned a literal string",
caps["decl"]))
}
return issues
}
func trimQuotes(s string) string {
if len(s) >= 2 {
return s[1 : len(s)-1]
}
return s
}
var evalDetectedQuery = mustTriQuery(`[
(call_expression function: (identifier) @fname (#eq? @fname "eval"))
(new_expression constructor: (identifier) @fname (#eq? @fname "Function"))
] @call`)
// vmRunQuery matches node:vm's runInNewContext/runInThisContext/runInContext.
// Node's own docs are explicit that the vm module is "not a security
// mechanism" and code run through it can escape the sandbox — same
// "executes arbitrary code" tradeoff as eval()/new Function(), just via a
// different API.
var vmRunQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @meth) (#eq? @obj "vm") (#any-of? @meth "runInNewContext" "runInThisContext" "runInContext")) @call`)
func checkJSEvalDetected(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range evalDetectedQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
fname := string(caps["fname"].Text(src))
issues = append(issues, jsIssueAt("js-eval-detected", "HIGH", path,
fname+"() used", fname+"() executes arbitrary code; avoid it on any input that isn't fully trusted",
caps["call"]))
}
for _, m := range vmRunQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
issues = append(issues, jsIssueAt("js-eval-detected", "HIGH", path,
"vm."+string(caps["meth"].Text(src))+"() used", "vm."+string(caps["meth"].Text(src))+"() executes arbitrary code; Node's docs explicitly state the vm module is not a security sandbox — avoid it on any input that isn't fully trusted",
caps["call"]))
}
return issues
}
var (
childProcessExecQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#any-of? @obj "child_process" "cp") (#any-of? @meth "exec" "execSync")) @call`)
childProcessSpawnShellQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @meth) arguments: (arguments (object (pair key: (property_identifier) @key value: (true)))) (#any-of? @obj "child_process" "cp") (#any-of? @meth "spawn" "execFile") (#eq? @key "shell")) @call`)
)
func checkJSCommandInjection(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range childProcessExecQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if !jsIsDynamicString(caps["arg"], lang, src) && !jsTaintedArg(caps["arg"], lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-command-injection", "HIGH", path,
"Command built from a non-literal argument",
string(caps["obj"].Text(src))+"."+string(caps["meth"].Text(src))+" argument is built via template-literal interpolation or concatenation instead of a literal, or is a local variable derived from request/env input; prefer execFile/spawn with an argument array",
caps["call"]))
}
for _, m := range childProcessSpawnShellQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
issues = append(issues, jsIssueAt("js-command-injection", "HIGH", path,
"spawn/execFile called with shell: true",
string(caps["obj"].Text(src))+"."+string(caps["meth"].Text(src))+"(..., { shell: true }) invokes a shell, reintroducing the same injection risk execFile/spawn's argument-array form exists to avoid",
caps["call"]))
}
return issues
}
var sqlQueryCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#any-of? @meth "query" "execute")) @call`)
func checkJSSQLInjection(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range sqlQueryCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if !jsIsDynamicString(caps["arg"], lang, src) && !jsTaintedArg(caps["arg"], lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
string(caps["meth"].Text(src))+" query argument is built via template-literal interpolation or concatenation instead of parameterized placeholders, or is a local variable derived from request/env input",
caps["call"]))
}
return issues
}
var createHashQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @fn) arguments: (arguments (string) @alg) (#eq? @obj "crypto") (#eq? @fn "createHash")) @call`)
func checkJSWeakHash(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range createHashQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
alg := trimQuotes(string(caps["alg"].Text(src)))
if alg != "md5" && alg != "sha1" {
continue
}
issues = append(issues, jsIssueAt("js-weak-hash", "LOW", path,
"Weak hash algorithm", "crypto.createHash('"+alg+"') is cryptographically broken; use 'sha256' or stronger",
caps["call"]))
}
return issues
}
var createCipherQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @fn) arguments: (arguments . (string) @alg) (#eq? @obj "crypto") (#any-of? @fn "createCipheriv" "createCipher" "createDecipheriv" "createDecipher")) @call`)
// checkJSWeakCipher flags crypto.createCipher(iv)/createDecipher(iv) called
// with a broken cipher (DES/RC4) or an insecure mode (ECB) — same
// name-in-algorithm-string signal as java-weak-cipher, just against Node's
// OpenSSL-style algorithm identifiers ("des-ede3-cbc", "rc4", "aes-128-ecb")
// instead of Java's.
func checkJSWeakCipher(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range createCipherQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
alg := trimQuotes(string(caps["alg"].Text(src)))
upper := strings.ToUpper(alg)
if !strings.Contains(upper, "DES") && !strings.Contains(upper, "RC4") && !strings.Contains(upper, "ECB") {
continue
}
issues = append(issues, jsIssueAt("js-weak-cipher", "MEDIUM", path,
"Weak cipher or insecure mode", "crypto."+string(caps["fn"].Text(src))+"('"+alg+"', ...) uses a broken cipher or an insecure mode (ECB); use AES-GCM instead",
caps["call"]))
}
return issues
}
var (
mathRandomQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @fn) (#eq? @obj "Math") (#eq? @fn "random")) @call`)
jsFuncDeclQuery = mustTriQuery(`(function_declaration name: (identifier) @fname body: (statement_block) @body) @def`)
jsFreeCallQuery = mustTriQuery(`(call_expression function: (identifier) @fn arguments: (arguments) @args) @call`)
)
func checkJSInsecureRandom(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsFuncDeclQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
fname := string(caps["fname"].Text(src))
if !nameLooksSecret(fname) && !strings.Contains(strings.ToLower(fname), "session") {
continue
}
for _, rm := range mathRandomQuery.forLang(lang).ExecuteNode(caps["body"], lang, src) {
issues = append(issues, jsIssueAt("js-insecure-random-for-secrets", "INFO", path,
"Math.random used in a security-sounding function",
"function "+fname+" uses Math.random, which is not cryptographically secure; consider the crypto module's randomBytes/randomUUID",
jsCapMap(rm)["call"]))
}
}
return issues
}
var rejectUnauthorizedQuery = mustTriQuery(`(pair key: (property_identifier) @key value: (false) (#eq? @key "rejectUnauthorized")) @pair`)
func checkJSTLSVerifyDisabled(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rejectUnauthorizedQuery.forLang(lang).ExecuteNode(root, lang, src) {
issues = append(issues, jsIssueAt("js-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", "rejectUnauthorized: false disables certificate validation",
jsCapMap(m)["pair"]))
}
return issues
}
var innerHTMLAssignQuery = mustTriQuery(`(assignment_expression left: (member_expression property: (property_identifier) @prop) right: (_) @val (#eq? @prop "innerHTML")) @assign`)
func checkJSDOMXSSInnerHTML(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range innerHTMLAssignQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if caps["val"].Type(lang) == "string" {
continue // literal HTML, not attacker-influenced
}
issues = append(issues, jsIssueAt("js-dom-xss-innerhtml", "MEDIUM", path,
"innerHTML assigned a non-literal value", ".innerHTML = ... with a non-literal right-hand side can lead to DOM-based XSS; prefer .textContent or a sanitizer",
caps["assign"]))
}
return issues
}
var dangerouslySetInnerHTMLQuery = mustJSXQuery(`(jsx_attribute (property_identifier) @attr (#eq? @attr "dangerouslySetInnerHTML")) @jsxattr`)
func checkJSReactDangerouslySetInnerHTML(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
q := dangerouslySetInnerHTMLQuery.forLang(lang)
if q == nil {
return nil
}
var issues []model.Issue
for _, m := range q.ExecuteNode(root, lang, src) {
issues = append(issues, jsIssueAt("js-react-dangerously-set-innerhtml", "MEDIUM", path,
"dangerouslySetInnerHTML used", "dangerouslySetInnerHTML bypasses React's escaping; ensure the __html value is sanitized",
jsCapMap(m)["jsxattr"]))
}
return issues
}
var redirectCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#eq? @meth "redirect")) @call`)
func checkJSOpenRedirect(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range redirectCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsIsDynamicString(arg, lang, src) && !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-open-redirect", "MEDIUM", path,
"Redirect target built from request data", "redirect(...) argument is derived from request input (directly, or through a local variable) rather than a literal/allowlisted URL",
caps["call"]))
}
return issues
}
var jsFuncBoundary = map[string]bool{
"function_declaration": true, "function_expression": true, "arrow_function": true,
"method_definition": true, "generator_function_declaration": true, "generator_function": true,
}
func jsAssignInfo(n *gts.Node, lang *gts.Language, src []byte) (string, *gts.Node, bool) {
switch n.Type(lang) {
case "variable_declarator":
name := n.ChildByFieldName("name", lang)
val := n.ChildByFieldName("value", lang)
if name == nil || val == nil || name.Type(lang) != "identifier" {
return "", nil, false
}
return string(name.Text(src)), val, true
case "assignment_expression":
left := n.ChildByFieldName("left", lang)
right := n.ChildByFieldName("right", lang)
if left == nil || right == nil || left.Type(lang) != "identifier" {
return "", nil, false
}
return string(left.Text(src)), right, true
default:
return "", nil, false
}
}
// jsIsEnvSource matches process.env.X/process.env['X'] by raw text rather
// than decomposing the member/subscript-expression shape.
func jsIsEnvSource(n *gts.Node, _ *gts.Language, src []byte) bool {
text := string(n.Text(src))
return strings.HasPrefix(text, "process.env.") || strings.HasPrefix(text, "process.env[")
}
// jsExprTainted reports whether n evaluates from tainted input: rooted at
// req/request (rootedAtRequest), an env-var read, a variable already
// known-tainted in env, or built from any of those via `+` concatenation,
// template-literal interpolation, or a call's arguments.
func jsExprTainted(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
if rootedAtRequest(n, lang, src) || jsIsEnvSource(n, lang, src) {
return true
}
switch n.Type(lang) {
case "identifier":
return env[string(n.Text(src))]
case "binary_expression":
op := n.ChildByFieldName("operator", lang)
if op == nil || string(op.Text(src)) != "+" {
return false
}
return jsExprTainted(n.ChildByFieldName("left", lang), lang, src, env) || jsExprTainted(n.ChildByFieldName("right", lang), lang, src, env)
case "template_string":
for _, c := range n.Children() {
if c.Type(lang) != "template_substitution" || c.NamedChildCount() == 0 {
continue
}
if jsExprTainted(c.NamedChild(0), lang, src, env) {
return true
}
}
return false
case "call_expression":
args := n.ChildByFieldName("arguments", lang)
if args == nil {
return false
}
for _, a := range args.Children() {
if jsExprTainted(a, lang, src, env) {
return true
}
}
return false
case "parenthesized_expression":
if n.NamedChildCount() > 0 {
return jsExprTainted(n.NamedChild(0), lang, src, env)
}
return false
default:
return false
}
}
// jsTaintedArg reports whether arg evaluates from tainted input, tracking
// through local variable assignments within its enclosing function/arrow/
// method (intraprocedural taint tracking — see taint_ts.go).
func jsTaintedArg(arg *gts.Node, lang *gts.Language, src []byte) bool {
body := tsEnclosingBody(arg, lang, jsFuncBoundary)
env := tsTaintEnv(body, lang, src, jsFuncBoundary, jsAssignInfo, jsExprTainted)
return jsExprTainted(arg, lang, src, env)
}
func rootedAtRequest(n *gts.Node, lang *gts.Language, src []byte) bool {
for n.Type(lang) == "member_expression" {
obj := n.ChildByFieldName("object", lang)
if obj == nil {
return false
}
n = obj
}
if n.Type(lang) != "identifier" {
return false
}
name := string(n.Text(src))
return name == "req" || name == "request"
}
var jwtAlgorithmNoneQuery = mustTriQuery(`(pair key: (property_identifier) @key value: (string) @val (#eq? @key "algorithm")) @pair`)
func checkJSJWTNoneAlgorithm(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jwtAlgorithmNoneQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if trimQuotes(string(caps["val"].Text(src))) != "none" {
continue
}
issues = append(issues, jsIssueAt("js-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "algorithm: 'none' accepts unsigned tokens, allowing signature bypass",
caps["pair"]))
}
return issues
}
var jsYamlLoadQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @fn) arguments: (arguments . (_) @arg) @args (#any-of? @obj "yaml" "YAML") (#eq? @fn "load")) @call`)
// checkJSYAMLUnsafeLoad flags js-yaml's load() with no options argument (or
// one with no "schema" key) — versions before js-yaml v4 default to a schema
// that can construct arbitrary JS types from untrusted YAML. An explicit
// `schema:` option is treated as a deliberate choice (hardened or not) and
// skipped, same false-positive-avoidance tradeoff as java-yaml-unsafe-load's
// zero-constructor-arg check.
func checkJSYAMLUnsafeLoad(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsYamlLoadQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if jsArgsHaveSchemaOption(caps["args"], lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-yaml-unsafe-load", "MEDIUM", path,
"yaml.load without an explicit schema", string(caps["obj"].Text(src))+".load(...) without a restrictive `schema` option can construct arbitrary types from untrusted YAML on js-yaml versions before v4",
caps["call"]))
}
return issues
}
// jsArgsHaveSchemaOption reports whether an arguments node's second argument
// is an object literal containing a "schema" key.
func jsArgsHaveSchemaOption(args *gts.Node, lang *gts.Language, src []byte) bool {
if args == nil || args.NamedChildCount() < 2 {
return false
}
opts := args.NamedChild(1)
if opts.Type(lang) != "object" {
return false
}
for _, c := range opts.Children() {
if c.Type(lang) != "pair" {
continue
}
key := c.ChildByFieldName("key", lang)
if key != nil && string(key.Text(src)) == "schema" {
return true
}
}
return false
}
var corsHeaderCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments . (string) @key . (string) @val) (#any-of? @meth "setHeader" "header")) @call`)
func checkJSCORSWildcard(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range corsHeaderCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
key := trimQuotes(string(caps["key"].Text(src)))
val := trimQuotes(string(caps["val"].Text(src)))
if !strings.EqualFold(key, "Access-Control-Allow-Origin") || val != "*" {
continue
}
issues = append(issues, jsIssueAt("js-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard", `setHeader("Access-Control-Allow-Origin", "*") allows any origin to make credentialed cross-origin requests`,
caps["call"]))
}
return issues
}
var (
cookieFlagFalseQuery = mustTriQuery(`(pair key: (property_identifier) @key value: (false)) @pair`)
sameSiteValueQuery = mustTriQuery(`(pair key: (property_identifier) @key value: (string) @val (#eq? @key "sameSite")) @pair`)
)
func checkJSInsecureCookie(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range cookieFlagFalseQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
key := string(caps["key"].Text(src))
if key != "httpOnly" && key != "secure" {
continue
}
issues = append(issues, jsIssueAt("js-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", key+": false in a cookie options object weakens cookie protection",
caps["pair"]))
}
for _, m := range sameSiteValueQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
val := strings.Trim(strings.ToLower(string(caps["val"].Text(src))), `'"`)
if val != "none" {
continue
}
obj := caps["pair"].Parent()
if obj == nil || jsObjectHasSecureTrue(obj, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-insecure-cookie", "MEDIUM", path,
"SameSite=None cookie without Secure",
"sameSite: 'none' is set without secure: true in the same options object — SameSite=None requires Secure or modern browsers reject the cookie outright, and without Secure the cookie is also sent over plain HTTP",
caps["pair"]))
}
return issues
}
// jsObjectHasSecureTrue reports whether obj (an object-literal node) has a
// sibling `secure: true` pair.
func jsObjectHasSecureTrue(obj *gts.Node, lang *gts.Language, src []byte) bool {
for _, c := range obj.Children() {
if c.Type(lang) != "pair" {
continue
}
key := c.ChildByFieldName("key", lang)
val := c.ChildByFieldName("value", lang)
if key == nil || val == nil {
continue
}
if string(key.Text(src)) == "secure" && val.Type(lang) == "true" {
return true
}
}
return false
}
var fsReadCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#any-of? @meth "readFile" "readFileSync" "createReadStream")) @call`)
func checkJSPathTraversal(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range fsReadCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsIsDynamicString(arg, lang, src) && !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-path-traversal", "HIGH", path,
"File path built from request data", "fs read call path is derived from request input (directly, or through a local variable) or built via template-literal interpolation/concatenation rather than a validated literal; sanitize/allowlist before use",
caps["call"]))
}
return issues
}
var (
fetchCallQuery = mustTriQuery(`(call_expression function: (identifier) @fname arguments: (arguments . (_) @arg) (#eq? @fname "fetch")) @call`)
axiosCallQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#eq? @obj "axios") (#any-of? @meth "get" "post" "put" "delete")) @call`)
)
func checkJSSSRF(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range fetchCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsIsDynamicString(arg, lang, src) && !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"fetch(...) URL argument is derived from request/env input (directly, or through a local variable) or built via template-literal interpolation/concatenation rather than a validated/allowlisted URL",
caps["call"]))
}
for _, m := range axiosCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsIsDynamicString(arg, lang, src) && !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"axios."+string(caps["meth"].Text(src))+"(...) URL argument is derived from request/env input (directly, or through a local variable) or built via template-literal interpolation/concatenation rather than a validated/allowlisted URL",
caps["call"]))
}
return issues
}
var ejsCallQuery = mustTriQuery(`(call_expression function: (member_expression object: (identifier) @obj property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#eq? @obj "ejs") (#any-of? @meth "render" "compile")) @call`)
func checkJSSSTI(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range ejsCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsIsDynamicString(arg, lang, src) && !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-ssti", "HIGH", path,
"Template source built from request data",
"ejs."+string(caps["meth"].Text(src))+"(...) argument is derived from request/env input (directly, or through a local variable) or built via template-literal interpolation/concatenation — the template source itself is attacker-controlled, which is server-side template injection, not just a data-substitution issue",
caps["call"]))
}
return issues
}
var jsMongoQueryCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments . (_) @arg) (#any-of? @meth "find" "findOne" "findOneAndUpdate" "findOneAndDelete" "updateOne" "updateMany" "deleteOne" "deleteMany")) @call`)
// checkJSNoSQLi flags a Mongoose/MongoDB query/update/delete call whose
// filter argument is entirely request/env-derived (`Model.find(req.body)`),
// not a literal filter with individually-typed fields — a different shape
// from SQL injection (no string concatenation to point at; the whole
// filter object being attacker-controlled is what lets operators like
// $ne/$gt through).
func checkJSNoSQLi(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsMongoQueryCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-nosqli", "HIGH", path,
"MongoDB query filter built entirely from request data",
string(caps["meth"].Text(src))+"(...) filter argument is derived from request/env input rather than a literal filter with individually-typed fields — passing the whole request payload as a MongoDB filter lets an attacker inject query operators (e.g. $ne, $gt) to bypass intended matching",
caps["call"]))
}
return issues
}
var requireCallQuery = mustTriQuery(`(call_expression function: (identifier) @fname arguments: (arguments . (_) @arg) (#eq? @fname "require")) @call`)
// checkJSUnsafeReflection flags require(...) when the module-specifier
// argument is itself tainted (request/env-derived, directly or through a
// local variable) — not gated on jsIsDynamicString: `require('./plugins/' +
// name)`-shaped dynamic-but-not-attacker-controlled requires are a common,
// legitimate plugin-loading idiom in Node, so only the taint check is used
// (same reasoning as the other *-unsafe-reflection rules).
func checkJSUnsafeReflection(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range requireCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
arg := caps["arg"]
if !jsTaintedArg(arg, lang, src) {
continue
}
issues = append(issues, jsIssueAt("js-unsafe-reflection", "HIGH", path,
"Module loaded by an attacker-controlled name",
"require(...) argument is derived from request/env input (directly, or through a local variable) — this loads/executes whatever module path an attacker supplies",
caps["call"]))
}
return issues
}
var jsCookieCallQuery = mustTriQuery(`(call_expression function: (member_expression property: (property_identifier) @meth) arguments: (arguments) @args (#eq? @meth "cookie")) @call`)
func checkJSCookieMissingFlags(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsCookieCallQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
args := caps["args"]
if args.NamedChildCount() < 3 {
issues = append(issues, jsIssueAt("js-cookie-missing-flags", "LOW", path,
"Cookie set without httpOnly/secure options", "cookie(...) called without an options object; httpOnly and secure both default to false, weakening cookie protection",
caps["call"]))
continue
}
opts := args.NamedChild(2)
if opts.Type(lang) != "object" {
continue // not a literal — can't introspect a variable/spread without data flow
}
has := map[string]bool{}
for _, c := range opts.Children() {
if c.Type(lang) != "pair" {
continue
}
key := c.ChildByFieldName("key", lang)
if key != nil {
has[string(key.Text(src))] = true
}
}
for _, flag := range []string{"httpOnly", "secure"} {
if has[flag] {
continue
}
issues = append(issues, jsIssueAt("js-cookie-missing-flags", "LOW", path,
flag+" not set on cookie options", "cookie(...) options object doesn't set "+flag+"; it defaults to false, weakening cookie protection unless set elsewhere",
opts))
}
}
return issues
}
// checkJSEmptyExceptionHandler flags an empty catch block, which silently
// swallows whatever it caught — SonarQube's S2486/S1166, ESLint's own
// core no-empty rule (catch clause) covers the identical shape.
var jsCatchQuery = mustTriQuery(`(catch_clause body: (statement_block) @body) @catch`)
func checkJSEmptyExceptionHandler(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsCatchQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, jsIssueAt("js-empty-exception-handler", "MEDIUM", path,
"Empty catch block", "catch (...) { } silently swallows the exception, hiding real failures; at minimum log it",
caps["catch"]))
}
return issues
}
// checkJSEmptyBlock flags an if/else/while/for body with no statements at
// all (SonarQube's S108) — almost always dead code, or (in the if-branch
// case) a silently-swallowed condition.
var (
jsIfBodyQuery = mustTriQuery(`(if_statement consequence: (statement_block) @body) @stmt`)
jsElseBodyQuery = mustTriQuery(`(else_clause (statement_block) @body) @stmt`)
jsWhileQuery = mustTriQuery(`(while_statement body: (statement_block) @body) @stmt`)
jsForBodyQuery = mustTriQuery(`(for_statement body: (statement_block) @body) @stmt`)
)
func checkJSEmptyBlock(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for shape, q := range map[string]triQuery{
"if": jsIfBodyQuery, "else": jsElseBodyQuery, "while": jsWhileQuery, "for": jsForBodyQuery,
} {
for _, m := range q.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, jsIssueAt("js-empty-block", "LOW", path,
"Empty "+shape+" block", shape+" body has no statements — likely dead code, or (if this is an error check) a silently-swallowed condition",
caps["stmt"]))
}
}
return issues
}
// checkJSUnreachableCode flags a statement immediately following a
// return/throw/break/continue in the same block — SonarQube's S1763.
var jsUnreachableQuery = mustTriQuery(`(statement_block [(return_statement) (throw_statement) (break_statement) (continue_statement)] @term . (_) @after)`)
func checkJSUnreachableCode(root *gts.Node, lang *gts.Language, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range jsUnreachableQuery.forLang(lang).ExecuteNode(root, lang, src) {
caps := jsCapMap(m)
issues = append(issues, jsIssueAt("js-unreachable-code", "LOW", path,
"Unreachable code", "this statement can never execute; it follows a "+string(caps["term"].Text(src))+" in the same block",
caps["after"]))
}
return issues
}
func jsCapMap(m gts.QueryMatch) map[string]*gts.Node {
out := make(map[string]*gts.Node, len(m.Captures))
for _, c := range m.Captures {
out[c.Name] = c.Node
}
return out
}
package sast
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
)
var phpLang = grammars.PhpLanguage()
func mustPHPQuery(src string) *gts.Query {
q, err := gts.NewQuery(src, phpLang)
if err != nil {
panic("sast: invalid php query: " + err.Error())
}
return q
}
type phpRule struct {
id string
severity string
check func(root *gts.Node, src []byte, path string) []model.Issue
}
var phpRules = []phpRule{
{"php-hardcoded-secret", "MEDIUM", checkPHPHardcodedSecret},
{"php-eval-detected", "HIGH", checkPHPEvalDetected},
{"php-command-injection", "HIGH", checkPHPCommandInjection},
{"php-sql-injection", "HIGH", checkPHPSQLInjection},
{"php-weak-hash", "LOW", checkPHPWeakHash},
{"php-weak-cipher", "MEDIUM", checkPHPWeakCipher},
{"php-insecure-deserialization", "HIGH", checkPHPUnserialize},
{"php-insecure-random-for-secrets", "INFO", checkPHPInsecureRandom},
{"php-tls-verify-disabled", "HIGH", checkPHPTLSVerifyDisabled},
{"php-lfi-include", "HIGH", checkPHPLFIInclude},
{"php-preg-replace-eval-modifier", "HIGH", checkPHPPregReplaceEvalModifier},
{"php-open-redirect", "MEDIUM", checkPHPOpenRedirect},
{"php-jwt-none-algorithm", "HIGH", checkPHPJWTNoneAlgorithm},
{"php-cors-wildcard", "MEDIUM", checkPHPCORSWildcard},
{"php-insecure-cookie", "MEDIUM", checkPHPInsecureCookie},
{"php-cookie-missing-flags", "LOW", checkPHPCookieMissingFlags},
{"php-ssrf", "HIGH", checkPHPSSRF},
{"php-xxe", "HIGH", checkPHPXXE},
{"php-nosqli", "HIGH", checkPHPNoSQLi},
{"php-unsafe-reflection", "HIGH", checkPHPUnsafeReflection},
{"php-predictable-prng-seed", "MEDIUM", checkPHPPredictablePRNGSeed},
{"php-mass-assignment", "MEDIUM", checkPHPMassAssignment},
{"php-empty-exception-handler", "MEDIUM", checkPHPEmptyExceptionHandler},
{"php-empty-block", "LOW", checkPHPEmptyBlock},
{"php-unreachable-code", "LOW", checkPHPUnreachableCode},
}
func phpIssueAt(id, severity, path, title, message string, n *gts.Node) model.Issue {
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: int(n.StartPoint().Row) + 1,
Message: message,
CWEs: cweFor(id),
}
}
func phpIsDynamicString(n *gts.Node) bool {
switch n.Type(phpLang) {
case "binary_expression":
op := n.ChildByFieldName("operator", phpLang)
return op != nil // "." is the only binary op PHP allows on strings in this position
case "encapsed_string", "heredoc":
return hasDescendant(n, phpLang, "variable_name")
default:
return false
}
}
var phpFuncBoundary = map[string]bool{
"function_definition": true, "method_declaration": true,
"anonymous_function": true, "arrow_function": true,
}
var phpSuperglobals = map[string]bool{
"$_GET": true, "$_POST": true, "$_REQUEST": true, "$_COOKIE": true, "$_SERVER": true, "$_FILES": true,
}
// phpRootedAtSuperglobal unwraps `$_GET['x']`-shaped subscript chains down
// to a bare variable_name, and reports whether that name is one of PHP's
// superglobal arrays. subscript_expression's base has no field name in
// this grammar (verified directly), hence NamedChild(0) rather than
// ChildByFieldName.
func phpRootedAtSuperglobal(n *gts.Node, src []byte) bool {
for {
switch n.Type(phpLang) {
case "subscript_expression":
if n.NamedChildCount() == 0 {
return false
}
n = n.NamedChild(0)
case "variable_name":
return phpSuperglobals[string(n.Text(src))]
default:
return false
}
if n == nil {
return false
}
}
}
// phpIsEnvSource matches getenv(...) by raw text.
func phpIsEnvSource(n *gts.Node, src []byte) bool {
return strings.HasPrefix(string(n.Text(src)), "getenv(")
}
func phpAssignInfo(n *gts.Node, lang *gts.Language, src []byte) (string, *gts.Node, bool) {
if n.Type(phpLang) != "assignment_expression" {
return "", nil, false
}
left := n.ChildByFieldName("left", phpLang)
right := n.ChildByFieldName("right", phpLang)
if left == nil || right == nil || left.Type(phpLang) != "variable_name" {
return "", nil, false
}
return string(left.Text(src)), right, true
}
// phpExprTainted reports whether n evaluates from tainted input: rooted at
// a superglobal (phpRootedAtSuperglobal), an env-var read, a variable
// already known-tainted in env, or built from any of those via `.`
// concatenation or a function call's arguments.
func phpExprTainted(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
if phpRootedAtSuperglobal(n, src) || phpIsEnvSource(n, src) {
return true
}
switch n.Type(phpLang) {
case "variable_name":
return env[string(n.Text(src))]
case "binary_expression":
return phpExprTainted(n.ChildByFieldName("left", phpLang), lang, src, env) || phpExprTainted(n.ChildByFieldName("right", phpLang), lang, src, env)
case "function_call_expression":
args := n.ChildByFieldName("arguments", phpLang)
if args == nil {
return false
}
for _, a := range args.Children() {
inner := a
if a.Type(phpLang) == "argument" && a.NamedChildCount() > 0 {
inner = a.NamedChild(0)
}
if phpExprTainted(inner, lang, src, env) {
return true
}
}
return false
default:
return false
}
}
// phpTaintedArg reports whether arg evaluates from tainted input, tracking
// through local variable assignments within its enclosing function/method/
// closure (intraprocedural taint tracking — see taint_ts.go).
func phpTaintedArg(arg *gts.Node, src []byte) bool {
body := tsEnclosingBody(arg, phpLang, phpFuncBoundary)
env := tsTaintEnv(body, phpLang, src, phpFuncBoundary, phpAssignInfo, phpExprTainted)
return phpExprTainted(arg, phpLang, src, env)
}
func hasDescendant(n *gts.Node, lang *gts.Language, typeName string) bool {
for _, c := range n.Children() {
if c.Type(lang) == typeName || hasDescendant(c, lang, typeName) {
return true
}
}
return false
}
func trimPHPQuotes(s string) string {
if len(s) >= 2 {
return s[1 : len(s)-1]
}
return s
}
var phpSecretAssignQuery = mustPHPQuery(`(assignment_expression left: (variable_name (name) @name) right: [(string) (encapsed_string)] @val) @assign`)
func checkPHPHardcodedSecret(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpSecretAssignQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
name := string(caps["name"].Text(src))
val := caps["val"]
if !nameLooksSecret(name) || hasDescendant(val, phpLang, "variable_name") {
continue
}
if len(trimPHPQuotes(string(val.Text(src)))) <= 4 {
continue
}
issues = append(issues, phpIssueAt("php-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable $"+name+" is assigned a literal string",
caps["assign"]))
}
return issues
}
var phpEvalQuery = mustPHPQuery(`(function_call_expression function: (name) @fname (#eq? @fname "eval")) @call`)
func checkPHPEvalDetected(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpEvalQuery.ExecuteNode(root, phpLang, src) {
issues = append(issues, phpIssueAt("php-eval-detected", "HIGH", path,
"eval() used", "eval() executes arbitrary code; avoid it on any input that isn't fully trusted",
phpCapMap(m)["call"]))
}
return issues
}
var phpCommandFuncQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (_) @arg)) (#any-of? @fname "system" "exec" "shell_exec" "passthru" "popen" "proc_open")) @call`)
func checkPHPCommandInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpCommandFuncQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if !phpIsDynamicString(caps["arg"]) && !phpTaintedArg(caps["arg"], src) {
continue
}
fname := string(caps["fname"].Text(src))
issues = append(issues, phpIssueAt("php-command-injection", "HIGH", path,
"Command built from a non-literal argument",
fname+"() argument is built via string concatenation or interpolation instead of a literal, or is a local variable derived from a superglobal/env input",
caps["call"]))
}
return issues
}
var phpSQLMemberCallQuery = mustPHPQuery(`(member_call_expression name: (name) @meth arguments: (arguments . (argument (_) @arg)) (#any-of? @meth "query" "exec")) @call`)
func checkPHPSQLInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpSQLMemberCallQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if !phpIsDynamicString(caps["arg"]) && !phpTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, phpIssueAt("php-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
"->"+string(caps["meth"].Text(src))+"(...) query argument is built via concatenation/interpolation instead of a prepared statement placeholder, or is a local variable derived from a superglobal/env input",
caps["call"]))
}
return issues
}
var (
phpHashFuncQuery = mustPHPQuery(`(function_call_expression function: (name) @fname) @call`)
phpHashWithAlgQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (string) @alg)) (#eq? @fname "hash")) @call`)
)
func checkPHPWeakHash(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpHashFuncQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
fname := string(caps["fname"].Text(src))
if fname != "md5" && fname != "sha1" {
continue
}
issues = append(issues, phpIssueAt("php-weak-hash", "LOW", path,
"Weak hash algorithm", fname+"() is cryptographically broken; use hash('sha256', ...) or stronger",
caps["call"]))
}
for _, m := range phpHashWithAlgQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
alg := trimPHPQuotes(string(caps["alg"].Text(src)))
if alg != "md5" && alg != "sha1" {
continue
}
issues = append(issues, phpIssueAt("php-weak-hash", "LOW", path,
"Weak hash algorithm", "hash('"+alg+"', ...) is cryptographically broken; use 'sha256' or stronger",
caps["call"]))
}
return issues
}
var phpOpensslCipherQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (_)) . (argument (string) @alg)) (#any-of? @fname "openssl_encrypt" "openssl_decrypt")) @call`)
// checkPHPWeakCipher flags openssl_encrypt/openssl_decrypt's second
// (cipher-method) argument naming a broken cipher (DES/RC4) or an insecure
// mode (ECB) — same name-in-algorithm-string signal as
// java-weak-cipher/js-weak-cipher, against PHP's OpenSSL method strings.
func checkPHPWeakCipher(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpOpensslCipherQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
alg := trimPHPQuotes(string(caps["alg"].Text(src)))
upper := strings.ToUpper(alg)
if !strings.Contains(upper, "DES") && !strings.Contains(upper, "RC4") && !strings.Contains(upper, "ECB") {
continue
}
issues = append(issues, phpIssueAt("php-weak-cipher", "MEDIUM", path,
"Weak cipher or insecure mode", string(caps["fname"].Text(src))+"(..., '"+alg+"', ...) uses a broken cipher or an insecure mode (ECB); use 'aes-256-gcm' instead",
caps["call"]))
}
return issues
}
var phpUnserializeQuery = mustPHPQuery(`(function_call_expression function: (name) @fname (#eq? @fname "unserialize")) @call`)
func checkPHPUnserialize(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpUnserializeQuery.ExecuteNode(root, phpLang, src) {
issues = append(issues, phpIssueAt("php-insecure-deserialization", "HIGH", path,
"Insecure deserialization via unserialize", "unserialize() can instantiate arbitrary objects and trigger PHP object injection when given untrusted data; use json_decode for plain data",
phpCapMap(m)["call"]))
}
return issues
}
var (
phpRandFuncQuery = mustPHPQuery(`(function_call_expression function: (name) @fname (#any-of? @fname "rand" "mt_rand")) @call`)
phpFuncDefQuery = mustPHPQuery(`(function_definition name: (name) @fname body: (compound_statement) @body) @def`)
phpFreeCallQuery = mustPHPQuery(`(function_call_expression function: (name) @fn arguments: (arguments) @args) @call`)
)
func checkPHPInsecureRandom(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpFuncDefQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
fname := string(caps["fname"].Text(src))
if !nameLooksSecret(fname) && !strings.Contains(strings.ToLower(fname), "session") {
continue
}
for _, rm := range phpRandFuncQuery.ExecuteNode(caps["body"], phpLang, src) {
rcaps := phpCapMap(rm)
issues = append(issues, phpIssueAt("php-insecure-random-for-secrets", "INFO", path,
string(rcaps["fname"].Text(src))+"() used in a security-sounding function",
"function "+fname+" uses "+string(rcaps["fname"].Text(src))+"(), which is not cryptographically secure; consider random_bytes()/random_int()",
rcaps["call"]))
}
}
return issues
}
var (
phpVerifyPeerFalseQuery = mustPHPQuery(`(array_element_initializer (string) @key (boolean) @val (#any-of? @key "'verify_peer'" "'verify_peer_name'" "\"verify_peer\"" "\"verify_peer_name\"")) @pair`)
phpCurlSSLVerifyQuery = mustPHPQuery(`(function_call_expression function: (name) @fn arguments: (arguments (argument (variable_name)) (argument (name) @opt) (argument (boolean) @val)) (#eq? @fn "curl_setopt") (#any-of? @opt "CURLOPT_SSL_VERIFYPEER" "CURLOPT_SSL_VERIFYHOST")) @call`)
)
func checkPHPTLSVerifyDisabled(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpVerifyPeerFalseQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if caps["val"].Type(phpLang) != "boolean" || string(caps["val"].Text(src)) != "false" {
continue
}
issues = append(issues, phpIssueAt("php-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", trimPHPQuotes(string(caps["key"].Text(src)))+" => false disables certificate validation",
caps["pair"]))
}
for _, m := range phpCurlSSLVerifyQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if string(caps["val"].Text(src)) != "false" {
continue
}
issues = append(issues, phpIssueAt("php-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", "curl_setopt(..., "+string(caps["opt"].Text(src))+", false) disables certificate validation",
caps["call"]))
}
return issues
}
var phpIncludeQuery = mustPHPQuery(`[
(include_expression (_) @arg) @inc
(include_once_expression (_) @arg) @inc
(require_expression (_) @arg) @inc
(require_once_expression (_) @arg) @inc
] `)
func checkPHPLFIInclude(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpIncludeQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
if arg.Type(phpLang) == "parenthesized_expression" {
if arg.NamedChildCount() == 0 {
continue
}
arg = arg.NamedChild(0)
}
if arg.Type(phpLang) == "string" {
continue // literal path, not attacker-influenced
}
keyword := strings.TrimSuffix(caps["inc"].Type(phpLang), "_expression")
issues = append(issues, phpIssueAt("php-lfi-include", "HIGH", path,
"File include path built from a non-literal value", keyword+" argument is not a literal path; this can lead to local/remote file inclusion if attacker-influenced",
caps["inc"]))
}
return issues
}
var phpPregReplaceQuery = mustPHPQuery(`(function_call_expression function: (name) @fn arguments: (arguments . (argument (string (string_content) @pat))) (#eq? @fn "preg_replace")) @call`)
func checkPHPPregReplaceEvalModifier(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpPregReplaceQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
pat := string(caps["pat"].Text(src))
if !strings.HasSuffix(pat, "e") || len(pat) < 2 {
continue
}
delim := pat[0]
if delim != '/' && delim != '#' && delim != '~' {
continue
}
if strings.LastIndexByte(pat[:len(pat)-1], delim) < 0 {
continue
}
issues = append(issues, phpIssueAt("php-preg-replace-eval-modifier", "HIGH", path,
"preg_replace with the /e modifier", "the /e modifier evaluates the replacement as PHP code — removed in PHP 7+, but still a critical RCE if present on an older runtime",
caps["call"]))
}
return issues
}
var (
phpFileGetContentsQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (_) @arg)) (#eq? @fname "file_get_contents")) @call`)
phpCurlURLQuery = mustPHPQuery(`(function_call_expression function: (name) @fn arguments: (arguments (argument (variable_name)) (argument (name) @opt) (argument (_) @arg)) (#eq? @fn "curl_setopt") (#eq? @opt "CURLOPT_URL")) @call`)
)
func checkPHPSSRF(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpFileGetContentsQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
if !phpIsDynamicString(arg) && !phpTaintedArg(arg, src) {
continue
}
issues = append(issues, phpIssueAt("php-ssrf", "HIGH", path,
"Outbound request URL built from a non-literal value",
"file_get_contents(...) argument is built via concatenation/interpolation, or is a local variable derived from a superglobal/env input, rather than a validated/allowlisted URL",
caps["call"]))
}
for _, m := range phpCurlURLQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
if !phpIsDynamicString(arg) && !phpTaintedArg(arg, src) {
continue
}
issues = append(issues, phpIssueAt("php-ssrf", "HIGH", path,
"Outbound request URL built from a non-literal value",
"curl_setopt(..., CURLOPT_URL, ...) value is built via concatenation/interpolation, or is a local variable derived from a superglobal/env input, rather than a validated/allowlisted URL",
caps["call"]))
}
return issues
}
var phpDisableEntityLoaderQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (boolean) @val)) (#eq? @fname "libxml_disable_entity_loader")) @call`)
func checkPHPXXE(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpDisableEntityLoaderQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if string(caps["val"].Text(src)) != "false" {
continue
}
issues = append(issues, phpIssueAt("php-xxe", "HIGH", path,
"XML external entity loading explicitly enabled", "libxml_disable_entity_loader(false) re-enables external XML entity loading, allowing XXE when parsing untrusted XML",
caps["call"]))
}
return issues
}
var phpMongoQueryCallQuery = mustPHPQuery(`(member_call_expression name: (name) @meth arguments: (arguments . (argument (_) @arg)) (#any-of? @meth "find" "findOne" "updateOne" "updateMany" "deleteOne" "deleteMany")) @call`)
// checkPHPNoSQLi flags a MongoDB driver query/update/delete call whose
// filter argument is entirely superglobal/env-derived, not a literal
// filter with individually-typed fields — a different shape from SQL
// injection (no concatenation to point at; the whole filter array being
// attacker-controlled is what lets operators like $ne/$gt through).
func checkPHPNoSQLi(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpMongoQueryCallQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
if !phpTaintedArg(arg, src) {
continue
}
issues = append(issues, phpIssueAt("php-nosqli", "HIGH", path,
"MongoDB query filter built entirely from request data",
"->"+string(caps["meth"].Text(src))+"(...) filter argument is derived from a superglobal/env input rather than a literal filter with individually-typed fields — passing the whole request payload as a MongoDB filter lets an attacker inject query operators (e.g. $ne, $gt) to bypass intended matching",
caps["call"]))
}
return issues
}
var phpCallUserFuncQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (_) @arg)) (#any-of? @fname "call_user_func" "call_user_func_array")) @call`)
// checkPHPUnsafeReflection flags call_user_func(_array) when the callback
// argument is itself tainted (superglobal/env-derived, directly or through
// a local variable) — calling an attacker-chosen function/method name,
// same "arbitrary invocation" gadget class as Ruby's send(tainted).
func checkPHPUnsafeReflection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpCallUserFuncQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
if !phpTaintedArg(arg, src) {
continue
}
issues = append(issues, phpIssueAt("php-unsafe-reflection", "HIGH", path,
"Function invoked by an attacker-controlled name",
string(caps["fname"].Text(src))+"(...) callback argument is derived from a superglobal/env input (directly, or through a local variable) — this calls whatever function/method name an attacker supplies",
caps["call"]))
}
return issues
}
var phpSrandQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (integer))) (#any-of? @fname "srand" "mt_srand")) @call`)
// checkPHPPredictablePRNGSeed flags srand(<literal>)/mt_srand(<literal>) —
// a fixed seed makes every subsequent rand()/mt_rand() value fully
// predictable (distinct from php-insecure-random-for-secrets, which flags
// the function choice, not the seed). Called with no args (the normal,
// safe usage) is unaffected.
func checkPHPPredictablePRNGSeed(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpSrandQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
fname := string(caps["fname"].Text(src))
issues = append(issues, phpIssueAt("php-predictable-prng-seed", "MEDIUM", path,
"PRNG seeded with a hardcoded literal",
fname+"(...) is called with a compile-time integer literal; every run produces the same sequence, making all subsequent output predictable",
caps["call"]))
}
return issues
}
var phpHeaderCallQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments . (argument (_) @arg)) (#eq? @fname "header")) @call`)
func checkPHPOpenRedirect(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpHeaderCallQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
arg := caps["arg"]
text := strings.ToLower(string(arg.Text(src)))
if !strings.Contains(text, "location:") || !phpIsDynamicString(arg) {
continue
}
issues = append(issues, phpIssueAt("php-open-redirect", "MEDIUM", path,
"Redirect target built from a non-literal value",
`header("Location: ...") value is built via concatenation/interpolation instead of a literal/allowlisted URL`,
caps["call"]))
}
return issues
}
func checkPHPCORSWildcard(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpHeaderCallQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
text := strings.ToLower(strings.TrimSpace(trimPHPQuotes(string(caps["arg"].Text(src)))))
if !strings.Contains(text, "access-control-allow-origin") || !strings.HasSuffix(text, "*") {
continue
}
issues = append(issues, phpIssueAt("php-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard",
`header("Access-Control-Allow-Origin: *") allows any origin to make credentialed cross-origin requests`,
caps["call"]))
}
return issues
}
var phpArrayPairQuery = mustPHPQuery(`(array_element_initializer (string) @key (string) @val) @pair`)
func checkPHPJWTNoneAlgorithm(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpArrayPairQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
key := strings.ToLower(trimPHPQuotes(string(caps["key"].Text(src))))
val := strings.ToLower(trimPHPQuotes(string(caps["val"].Text(src))))
if key != "alg" || val != "none" {
continue
}
issues = append(issues, phpIssueAt("php-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "'alg' => 'none' accepts unsigned tokens, allowing signature bypass",
caps["pair"]))
}
return issues
}
var phpBoolArrayPairQuery = mustPHPQuery(`(array_element_initializer (string) @key (boolean) @val) @pair`)
func checkPHPInsecureCookie(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpBoolArrayPairQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
key := strings.ToLower(trimPHPQuotes(string(caps["key"].Text(src))))
val := string(caps["val"].Text(src))
if (key != "secure" && key != "httponly") || val != "false" {
continue
}
issues = append(issues, phpIssueAt("php-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", "'"+key+"' => false weakens cookie protection",
caps["pair"]))
}
return issues
}
var phpSetCookieCallQuery = mustPHPQuery(`(function_call_expression function: (name) @fname arguments: (arguments) @args (#eq? @fname "setcookie")) @call`)
func checkPHPCookieMissingFlags(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpSetCookieCallQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
var argExprs []*gts.Node
for _, c := range caps["args"].Children() {
if c.Type(phpLang) == "argument" && c.NamedChildCount() > 0 {
argExprs = append(argExprs, c.NamedChild(0))
}
}
if len(argExprs) >= 3 && argExprs[2].Type(phpLang) == "array_creation_expression" {
has := map[string]bool{}
for _, c := range argExprs[2].Children() {
if c.Type(phpLang) != "array_element_initializer" || c.NamedChildCount() < 1 {
continue
}
has[strings.ToLower(trimPHPQuotes(string(c.NamedChild(0).Text(src))))] = true
}
for _, flag := range []string{"secure", "httponly"} {
if has[flag] {
continue
}
issues = append(issues, phpIssueAt("php-cookie-missing-flags", "LOW", path,
"'"+flag+"' not set on setcookie options", "setcookie(..., array $options) doesn't set '"+flag+"'; it defaults to false, weakening cookie protection unless set elsewhere",
caps["call"]))
}
continue
}
if len(argExprs) < 6 {
issues = append(issues, phpIssueAt("php-cookie-missing-flags", "LOW", path,
"secure/httponly not passed to setcookie", "setcookie(...) is missing the trailing $secure/$httponly parameters; they default to false, weakening cookie protection",
caps["call"]))
} else if len(argExprs) < 7 {
issues = append(issues, phpIssueAt("php-cookie-missing-flags", "LOW", path,
"httponly not passed to setcookie", "setcookie(...) is missing the trailing $httponly parameter; it defaults to false, weakening cookie protection",
caps["call"]))
}
}
return issues
}
// phpMassAssignInstanceQuery matches Laravel's instance-method mass-assignment
// sinks: $model->fill($request->all())/->update(...)/->forceFill(...).
// phpMassAssignStaticQuery matches the static form: Model::create(...).
// Both require the argument to be a direct ->all() call — a real filter
// array (even one built from request data per-key) doesn't match, same
// "whole-argument, not a value inside it" shape as ruby-mass-assignment and
// the NoSQLi rules' *TaintedArg checks.
var (
phpMassAssignInstanceQuery = mustPHPQuery(`(member_call_expression name: (name) @meth arguments: (arguments . (argument (member_call_expression name: (name) @innerMeth))) (#any-of? @meth "fill" "update" "forceFill") (#eq? @innerMeth "all")) @call`)
phpMassAssignStaticQuery = mustPHPQuery(`(scoped_call_expression name: (name) @meth arguments: (arguments . (argument (member_call_expression name: (name) @innerMeth))) (#eq? @meth "create") (#eq? @innerMeth "all")) @call`)
)
func checkPHPMassAssignment(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpMassAssignInstanceQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
meth := string(caps["meth"].Text(src))
issues = append(issues, phpIssueAt("php-mass-assignment", "MEDIUM", path,
"Mass assignment from unfiltered request input", "->"+meth+"($request->all()) assigns every request field to the model, including ones a real form never exposes; use $request->only([...]) or a $fillable/$guarded allowlist",
caps["call"]))
}
for _, m := range phpMassAssignStaticQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
issues = append(issues, phpIssueAt("php-mass-assignment", "MEDIUM", path,
"Mass assignment from unfiltered request input", "::create($request->all()) assigns every request field to the model, including ones a real form never exposes; use $request->only([...]) or a $fillable/$guarded allowlist",
caps["call"]))
}
return issues
}
// checkPHPEmptyExceptionHandler flags an empty catch block, which silently
// swallows whatever it caught — SonarQube's S2486/S1166 shape.
var phpCatchQuery = mustPHPQuery(`(catch_clause body: (compound_statement) @body) @catch`)
func checkPHPEmptyExceptionHandler(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpCatchQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, phpIssueAt("php-empty-exception-handler", "MEDIUM", path,
"Empty catch block", "catch (...) { } silently swallows the exception, hiding real failures; at minimum log it",
caps["catch"]))
}
return issues
}
// checkPHPEmptyBlock flags an if/else/while/for body with no statements at
// all (SonarQube's S108) — almost always dead code, or (in the if-branch
// case) a silently-swallowed condition.
var (
phpIfBodyQuery = mustPHPQuery(`(if_statement body: (compound_statement) @body) @stmt`)
phpElseBodyQuery = mustPHPQuery(`(else_clause body: (compound_statement) @body) @stmt`)
phpWhileQuery = mustPHPQuery(`(while_statement body: (compound_statement) @body) @stmt`)
phpForBodyQuery = mustPHPQuery(`(for_statement body: (compound_statement) @body) @stmt`)
)
func checkPHPEmptyBlock(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for shape, q := range map[string]*gts.Query{
"if": phpIfBodyQuery, "else": phpElseBodyQuery, "while": phpWhileQuery, "for": phpForBodyQuery,
} {
for _, m := range q.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, phpIssueAt("php-empty-block", "LOW", path,
"Empty "+shape+" block", shape+" body has no statements — likely dead code, or (if this is an error check) a silently-swallowed condition",
caps["stmt"]))
}
}
return issues
}
// checkPHPUnreachableCode flags a statement immediately following a
// return/throw/break/continue in the same block — SonarQube's S1763. PHP's
// grammar represents `throw` as an expression_statement wrapping a
// throw_expression, not a dedicated throw_statement type the way
// Java/JS/Python do — verified against a real parse tree before writing
// this query, not assumed from the other languages' shape.
var phpUnreachableQuery = mustPHPQuery(`(compound_statement [(expression_statement (throw_expression)) (return_statement) (break_statement) (continue_statement)] @term . (_) @after)`)
func checkPHPUnreachableCode(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range phpUnreachableQuery.ExecuteNode(root, phpLang, src) {
caps := phpCapMap(m)
issues = append(issues, phpIssueAt("php-unreachable-code", "LOW", path,
"Unreachable code", "this statement can never execute; it follows a "+string(caps["term"].Text(src))+" in the same block",
caps["after"]))
}
return issues
}
func phpCapMap(m gts.QueryMatch) map[string]*gts.Node {
out := make(map[string]*gts.Node, len(m.Captures))
for _, c := range m.Captures {
out[c.Name] = c.Node
}
return out
}
package sast
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
)
var pyLang = grammars.PythonLanguage()
func mustPyQuery(src string) *gts.Query {
q, err := gts.NewQuery(src, pyLang)
if err != nil {
panic("sast: invalid python query: " + err.Error())
}
return q
}
type pyRule struct {
id string
severity string
check func(root *gts.Node, src []byte, path string) []model.Issue
}
var pyRules = []pyRule{
{"py-hardcoded-secret", "MEDIUM", checkPyHardcodedSecret},
{"py-eval-exec", "HIGH", checkPyEvalExec},
{"py-command-injection", "HIGH", checkPyCommandInjection},
{"py-sql-injection", "HIGH", checkPySQLInjection},
{"py-weak-hash", "LOW", checkPyWeakHash},
{"py-weak-cipher", "MEDIUM", checkPyWeakCipher},
{"py-pickle-deserialization", "HIGH", checkPyPickle},
{"py-yaml-unsafe-load", "MEDIUM", checkPyYAMLUnsafeLoad},
{"py-insecure-random-for-secrets", "INFO", checkPyInsecureRandom},
{"py-tls-verify-disabled", "HIGH", checkPyTLSVerifyDisabled},
{"py-flask-debug-enabled", "MEDIUM", checkPyFlaskDebug},
{"py-jinja2-autoescape-disabled", "MEDIUM", checkPyJinja2Autoescape},
{"py-open-redirect", "MEDIUM", checkPyOpenRedirect},
{"py-jwt-verify-disabled", "HIGH", checkPyJWTVerifyDisabled},
{"py-cors-wildcard", "MEDIUM", checkPyCORSWildcard},
{"py-insecure-cookie", "MEDIUM", checkPyInsecureCookie},
{"py-path-traversal", "HIGH", checkPyPathTraversal},
{"py-cookie-missing-flags", "LOW", checkPyCookieMissingFlags},
{"py-ssrf", "HIGH", checkPySSRF},
{"py-xxe", "HIGH", checkPyXXE},
{"py-ssti", "HIGH", checkPySSTI},
{"py-nosqli", "HIGH", checkPyNoSQLi},
{"py-insecure-tempfile", "MEDIUM", checkPyInsecureTempfile},
{"py-unsafe-reflection", "HIGH", checkPyUnsafeReflection},
{"py-predictable-prng-seed", "MEDIUM", checkPyPredictablePRNGSeed},
{"py-agent-unsandboxed-exec", "HIGH", checkPyAgentUnsandboxedExec},
{"py-empty-exception-handler", "MEDIUM", checkPyEmptyExceptionHandler},
{"py-unreachable-code", "LOW", checkPyUnreachableCode},
}
func pyIssueAt(id, severity, path, title, message string, n *gts.Node) model.Issue {
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: int(n.StartPoint().Row) + 1,
Message: message,
CWEs: cweFor(id),
}
}
func fileImports(root *gts.Node, src []byte, modName string) bool {
q := fileImportsQuery
for _, m := range q.ExecuteNode(root, pyLang, src) {
for _, c := range m.Captures {
if c.Name == "mod" && string(c.Node.Text(src)) == modName {
return true
}
}
}
return false
}
var fileImportsQuery = mustPyQuery(`[
(import_statement name: (dotted_name (identifier) @mod))
(import_statement name: (aliased_import name: (dotted_name (identifier) @mod)))
(import_from_statement module_name: (dotted_name (identifier) @mod))
]`)
func pyIsDynamicString(n *gts.Node, src []byte) bool {
switch n.Type(pyLang) {
case "string":
for _, c := range n.Children() {
if c.Type(pyLang) == "interpolation" {
return true
}
}
return false
case "binary_operator":
return true // covers both "%s" % x and "a" + x
case "call":
fn := n.ChildByFieldName("function", pyLang)
return fn != nil && fn.Type(pyLang) == "attribute" &&
string(fn.ChildByFieldName("attribute", pyLang).Text(src)) == "format"
default:
return false
}
}
var secretAssignQuery = mustPyQuery(`(assignment left: (identifier) @name right: (string) @val) @assign`)
func checkPyHardcodedSecret(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range secretAssignQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
name := string(caps["name"].Text(src))
val := caps["val"]
if !nameLooksSecret(name) || pyIsDynamicString(val, src) {
continue
}
if len(strings.Trim(string(val.Text(src)), `"'`)) <= 4 {
continue
}
issues = append(issues, pyIssueAt("py-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+name+" is assigned a literal string",
caps["assign"]))
}
return issues
}
var evalExecQuery = mustPyQuery(`(call function: (identifier) @fname (#any-of? @fname "eval" "exec")) @call`)
func checkPyEvalExec(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range evalExecQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
fname := string(caps["fname"].Text(src))
issues = append(issues, pyIssueAt("py-eval-exec", "HIGH", path,
fname+"() used", fname+"() executes arbitrary code; avoid it on any input that isn't fully trusted",
caps["call"]))
}
return issues
}
var (
osSystemQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#eq? @mod "os") (#any-of? @fn "system" "popen")) @call`)
subprocessShellQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (true))) (#eq? @mod "subprocess") (#any-of? @fn "run" "call" "Popen" "check_call" "check_output") (#eq? @kwname "shell")) @call`)
)
func checkPyCommandInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range osSystemQuery.ExecuteNode(root, pyLang, src) {
fn := string(capMap(m)["fn"].Text(src))
issues = append(issues, pyIssueAt("py-command-injection", "HIGH", path,
"os."+fn+"() runs a shell command", "os."+fn+"() always invokes a shell; build the command with subprocess and a literal argument list instead",
capMap(m)["call"]))
}
for _, m := range subprocessShellQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
issues = append(issues, pyIssueAt("py-command-injection", "HIGH", path,
"subprocess call with shell=True", "subprocess."+string(caps["fn"].Text(src))+"(shell=True) invokes a shell; pass the command as an argument list with shell=False (the default) instead",
caps["call"]))
}
return issues
}
var sqlExecuteQuery = mustPyQuery(`(call function: (attribute attribute: (identifier) @meth) arguments: (argument_list . (_) @arg) (#any-of? @meth "execute" "executemany")) @call`)
func checkPySQLInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range sqlExecuteQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
if !pyIsDynamicString(caps["arg"], src) && !pyTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, pyIssueAt("py-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
string(caps["meth"].Text(src))+" query argument is built via f-string/%/concatenation/.format instead of parameter placeholders, or is a local variable derived from request/env input",
caps["call"]))
}
return issues
}
var weakHashQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#eq? @mod "hashlib") (#any-of? @fn "md5" "sha1")) @call`)
func checkPyWeakHash(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range weakHashQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
fn := string(caps["fn"].Text(src))
issues = append(issues, pyIssueAt("py-weak-hash", "LOW", path,
"Weak hash algorithm", "hashlib."+fn+" is cryptographically broken; use hashlib.sha256 or stronger",
caps["call"]))
}
return issues
}
var (
weakCipherClassQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#any-of? @mod "DES" "DES3" "ARC4" "Blowfish") (#eq? @fn "new")) @call`)
weakCipherModeQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list . (_) . (attribute attribute: (identifier) @mode)) (#eq? @fn "new") (#eq? @mode "MODE_ECB")) @call`)
)
// checkPyWeakCipher covers pycryptodome/PyCrypto's two independent ways to
// end up with a broken cipher: constructing an inherently weak cipher class
// (DES/DES3/ARC4/Blowfish — same name-list signal as go/java-weak-cipher),
// or constructing any cipher (including AES) in ECB mode, which leaks
// plaintext structure regardless of key strength. A call already flagged by
// the class check is skipped by the mode check so DES.new(key, DES.MODE_ECB)
// reports once, not twice.
func checkPyWeakCipher(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
weakClasses := map[string]bool{}
for _, m := range weakCipherClassQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
mod := string(caps["mod"].Text(src))
weakClasses[string(caps["call"].Text(src))] = true
issues = append(issues, pyIssueAt("py-weak-cipher", "MEDIUM", path,
"Weak cipher algorithm", mod+".new(...) is a broken cipher; use AES in GCM mode instead",
caps["call"]))
}
for _, m := range weakCipherModeQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
call := string(caps["call"].Text(src))
if weakClasses[call] {
continue // already reported above for the cipher class itself
}
issues = append(issues, pyIssueAt("py-weak-cipher", "MEDIUM", path,
"Insecure cipher mode (ECB)", string(caps["mod"].Text(src))+".new(..., "+string(caps["mod"].Text(src))+".MODE_ECB) leaks plaintext structure; use MODE_GCM instead",
caps["call"]))
}
return issues
}
var pickleQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#eq? @mod "pickle") (#any-of? @fn "load" "loads")) @call`)
func checkPyPickle(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pickleQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-pickle-deserialization", "HIGH", path,
"Insecure deserialization via pickle", "pickle.load(s) can execute arbitrary code when deserializing untrusted data",
capMap(m)["call"]))
}
return issues
}
var yamlLoadQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list) @args (#eq? @mod "yaml") (#eq? @fn "load")) @call`)
var safeYAMLLoaders = map[string]bool{"SafeLoader": true, "CSafeLoader": true}
func checkPyYAMLUnsafeLoad(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range yamlLoadQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
safe := false
for _, c := range caps["args"].Children() {
if c.Type(pyLang) != "keyword_argument" {
continue
}
if string(c.ChildByFieldName("name", pyLang).Text(src)) != "Loader" {
continue
}
val := c.ChildByFieldName("value", pyLang)
name := string(val.Text(src))
name = name[strings.LastIndex(name, ".")+1:]
safe = safeYAMLLoaders[name]
}
if safe {
continue
}
issues = append(issues, pyIssueAt("py-yaml-unsafe-load", "MEDIUM", path,
"yaml.load without a safe Loader", "yaml.load(...) without Loader=yaml.SafeLoader can construct arbitrary Python objects from untrusted YAML",
caps["call"]))
}
return issues
}
var (
randomCallQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod) (#eq? @mod "random")) @call`)
funcDefQuery = mustPyQuery(`(function_definition name: (identifier) @fname body: (block) @body) @def`)
pyFreeCallQuery = mustPyQuery(`(call function: (identifier) @fn arguments: (argument_list) @args) @call`)
)
func checkPyInsecureRandom(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range funcDefQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
fname := string(caps["fname"].Text(src))
if !nameLooksSecret(fname) && !strings.Contains(strings.ToLower(fname), "session") {
continue
}
for _, rm := range randomCallQuery.ExecuteNode(caps["body"], pyLang, src) {
issues = append(issues, pyIssueAt("py-insecure-random-for-secrets", "INFO", path,
"random module used in a security-sounding function",
"function "+fname+" uses the random module, which is not cryptographically secure; consider the secrets module",
capMap(rm)["call"]))
}
}
return issues
}
var (
requestsVerifyQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (false))) (#eq? @mod "requests") (#eq? @kwname "verify")) @call`)
sslUnverifiedQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#eq? @mod "ssl") (#eq? @fn "_create_unverified_context")) @call`)
)
func checkPyTLSVerifyDisabled(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range requestsVerifyQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", "requests call with verify=False disables certificate validation",
capMap(m)["call"]))
}
for _, m := range sslUnverifiedQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", "ssl._create_unverified_context() disables certificate validation",
capMap(m)["call"]))
}
return issues
}
var flaskRunDebugQuery = mustPyQuery(`(call function: (attribute attribute: (identifier) @meth) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (true))) (#eq? @meth "run") (#eq? @kwname "debug")) @call`)
func checkPyFlaskDebug(root *gts.Node, src []byte, path string) []model.Issue {
if !fileImports(root, src, "flask") {
return nil
}
var issues []model.Issue
for _, m := range flaskRunDebugQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-flask-debug-enabled", "MEDIUM", path,
"Flask debug mode enabled", "app.run(debug=True) exposes the Werkzeug interactive debugger, which allows remote code execution if reachable",
capMap(m)["call"]))
}
return issues
}
var jinja2EnvQuery = mustPyQuery(`(call function: (identifier) @fname arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (false))) (#eq? @fname "Environment") (#eq? @kwname "autoescape")) @call`)
func checkPyJinja2Autoescape(root *gts.Node, src []byte, path string) []model.Issue {
if !fileImports(root, src, "jinja2") {
return nil
}
var issues []model.Issue
for _, m := range jinja2EnvQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-jinja2-autoescape-disabled", "MEDIUM", path,
"Jinja2 autoescape disabled", "Environment(autoescape=False) disables automatic HTML escaping, opening the door to XSS",
capMap(m)["call"]))
}
return issues
}
var pyFuncBoundary = map[string]bool{"function_definition": true, "lambda": true}
func pyAssignInfo(n *gts.Node, lang *gts.Language, src []byte) (string, *gts.Node, bool) {
if n.Type(pyLang) != "assignment" {
return "", nil, false
}
left := n.ChildByFieldName("left", pyLang)
right := n.ChildByFieldName("right", pyLang)
if left == nil || right == nil || left.Type(pyLang) != "identifier" {
return "", nil, false
}
return string(left.Text(src)), right, true
}
// pyIsEnvSource matches os.getenv(...)/os.environ.get(...)/os.environ[...]
// by raw text rather than decomposing the call shape — cheap and good
// enough for a single-node source check.
func pyIsEnvSource(n *gts.Node, src []byte) bool {
text := string(n.Text(src))
return strings.HasPrefix(text, "os.getenv(") || strings.HasPrefix(text, "os.environ.get(") || strings.HasPrefix(text, "os.environ[")
}
// pyExprTainted reports whether n evaluates from tainted input: rooted at
// request (pyRootedAtRequest), an env-var read, a variable already
// known-tainted in env, or built from any of those via binary_operator
// (%/+ formatting) or a call's arguments.
func pyExprTainted(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
if pyRootedAtRequest(n, src) || pyIsEnvSource(n, src) {
return true
}
switch n.Type(pyLang) {
case "identifier":
return env[string(n.Text(src))]
case "binary_operator":
return pyExprTainted(n.ChildByFieldName("left", pyLang), lang, src, env) || pyExprTainted(n.ChildByFieldName("right", pyLang), lang, src, env)
case "call":
args := n.ChildByFieldName("arguments", pyLang)
if args == nil {
return false
}
for _, a := range args.Children() {
if pyExprTainted(a, lang, src, env) {
return true
}
}
return false
case "parenthesized_expression":
if n.NamedChildCount() > 0 {
return pyExprTainted(n.NamedChild(0), lang, src, env)
}
return false
default:
return false
}
}
// pyTaintedArg reports whether arg evaluates from tainted input, tracking
// through local variable assignments within its enclosing function/lambda
// (intraprocedural taint tracking — see taint_ts.go).
func pyTaintedArg(arg *gts.Node, src []byte) bool {
body := tsEnclosingBody(arg, pyLang, pyFuncBoundary)
env := tsTaintEnv(body, pyLang, src, pyFuncBoundary, pyAssignInfo, pyExprTainted)
return pyExprTainted(arg, pyLang, src, env)
}
func pyRootedAtRequest(n *gts.Node, src []byte) bool {
for {
switch n.Type(pyLang) {
case "attribute":
n = n.ChildByFieldName("object", pyLang)
case "call":
n = n.ChildByFieldName("function", pyLang)
case "subscript":
n = n.ChildByFieldName("value", pyLang)
case "identifier":
return string(n.Text(src)) == "request"
default:
return false
}
if n == nil {
return false
}
}
}
var pyRedirectCallQuery = mustPyQuery(`(call function: (identifier) @fname arguments: (argument_list . (_) @arg) (#eq? @fname "redirect")) @call`)
func checkPyOpenRedirect(root *gts.Node, src []byte, path string) []model.Issue {
if !fileImports(root, src, "flask") {
return nil
}
var issues []model.Issue
for _, m := range pyRedirectCallQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyIsDynamicString(arg, src) && !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-open-redirect", "MEDIUM", path,
"Redirect target built from request data", "redirect(...) argument is derived from request input (directly, or through a local variable) rather than a literal/allowlisted URL",
caps["call"]))
}
return issues
}
var pyJWTVerifyFalseQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (false))) (#eq? @mod "jwt") (#eq? @fn "decode") (#eq? @kwname "verify")) @call`)
func checkPyJWTVerifyDisabled(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyJWTVerifyFalseQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-jwt-verify-disabled", "HIGH", path,
"JWT signature verification disabled", "jwt.decode(..., verify=False) accepts tokens with any/no signature, allowing forgery",
capMap(m)["call"]))
}
return issues
}
var pySubscriptAssignQuery = mustPyQuery(`(assignment left: (subscript subscript: (string) @key) right: (string) @val) @assign`)
func checkPyCORSWildcard(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pySubscriptAssignQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
key := strings.Trim(string(caps["key"].Text(src)), `"'`)
val := strings.Trim(string(caps["val"].Text(src)), `"'`)
if !strings.EqualFold(key, "Access-Control-Allow-Origin") || val != "*" {
continue
}
issues = append(issues, pyIssueAt("py-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard", "headers['Access-Control-Allow-Origin'] = '*' allows any origin to make credentialed cross-origin requests",
caps["assign"]))
}
return issues
}
var pyCookieFalseQuery = mustPyQuery(`(call function: (attribute attribute: (identifier) @meth) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (false))) (#eq? @meth "set_cookie") (#any-of? @kwname "secure" "httponly")) @call`)
func checkPyInsecureCookie(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyCookieFalseQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
kw := string(caps["kwname"].Text(src))
issues = append(issues, pyIssueAt("py-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", "set_cookie(..., "+kw+"=False) weakens cookie protection",
caps["call"]))
}
return issues
}
var pyOpenCallQuery = mustPyQuery(`(call function: (identifier) @fname arguments: (argument_list . (_) @arg) (#eq? @fname "open")) @call`)
func checkPyPathTraversal(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyOpenCallQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyIsDynamicString(arg, src) && !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-path-traversal", "HIGH", path,
"File path built from request data", "open(...) path is derived from request input (directly, or through a local variable) or built via f-string/%/concatenation/.format rather than a validated literal; sanitize/allowlist before use",
caps["call"]))
}
return issues
}
var requestsSSRFQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list . (_) @arg) (#eq? @mod "requests") (#any-of? @fn "get" "post" "put" "delete" "head" "patch")) @call`)
func checkPySSRF(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range requestsSSRFQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyIsDynamicString(arg, src) && !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"requests."+string(caps["fn"].Text(src))+"(...) URL argument is derived from request/env input (directly, or through a local variable) or built via f-string/%/concatenation/.format rather than a validated/allowlisted URL",
caps["call"]))
}
return issues
}
var lxmlResolveEntitiesQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list (keyword_argument name: (identifier) @kwname value: (true))) (#eq? @mod "etree") (#eq? @fn "XMLParser") (#eq? @kwname "resolve_entities")) @call`)
func checkPyXXE(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range lxmlResolveEntitiesQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-xxe", "HIGH", path,
"XML entity resolution explicitly enabled", "lxml.etree.XMLParser(resolve_entities=True) allows external/internal entity expansion, enabling XXE and entity-expansion DoS when parsing untrusted XML",
capMap(m)["call"]))
}
return issues
}
var pyRenderTemplateStringQuery = mustPyQuery(`(call function: (identifier) @fname arguments: (argument_list . (_) @arg) (#eq? @fname "render_template_string")) @call`)
func checkPySSTI(root *gts.Node, src []byte, path string) []model.Issue {
if !fileImports(root, src, "flask") {
return nil
}
var issues []model.Issue
for _, m := range pyRenderTemplateStringQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyIsDynamicString(arg, src) && !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-ssti", "HIGH", path,
"Template source built from request data",
"render_template_string(...) argument is derived from request/env input (directly, or through a local variable) or built via f-string/%/concatenation/.format — the template source itself is attacker-controlled, which is server-side template injection, not just a data-substitution issue",
caps["call"]))
}
return issues
}
var pyMongoQueryCallQuery = mustPyQuery(`(call function: (attribute attribute: (identifier) @meth) arguments: (argument_list . (_) @arg) (#any-of? @meth "find" "find_one" "find_one_and_update" "find_one_and_delete" "update_one" "update_many" "delete_one" "delete_many")) @call`)
// checkPyNoSQLi flags a pymongo query/update/delete call whose filter
// argument is entirely request/env-derived, not a literal filter with
// individually-typed fields — a different shape from SQL injection
// (there's no string concatenation to point at; the whole filter object
// being attacker-controlled is what lets operators like $ne/$gt through).
func checkPyNoSQLi(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyMongoQueryCallQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-nosqli", "HIGH", path,
"MongoDB query filter built entirely from request data",
string(caps["meth"].Text(src))+"(...) filter argument is derived from request/env input rather than a literal filter with individually-typed fields — passing the whole request payload as a MongoDB filter lets an attacker inject query operators (e.g. $ne, $gt) to bypass intended matching",
caps["call"]))
}
return issues
}
var pyMktempQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) (#eq? @mod "tempfile") (#eq? @fn "mktemp")) @call`)
// checkPyInsecureTempfile flags tempfile.mktemp() unconditionally: it
// returns a predictable, not-yet-created filename with no safe usage —
// that's exactly why Python's own docs deprecate it in favor of
// NamedTemporaryFile()/mkstemp(), which atomically create the file.
func checkPyInsecureTempfile(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyMktempQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-insecure-tempfile", "MEDIUM", path,
"Insecure temporary file name", "tempfile.mktemp() returns a predictable, not-yet-created filename — a race condition (TOCTOU) lets another process create/symlink the same path first; use tempfile.NamedTemporaryFile()/mkstemp() instead",
capMap(m)["call"]))
}
return issues
}
var pyImportModuleQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list . (_) @arg) (#eq? @mod "importlib") (#eq? @fn "import_module")) @call`)
// checkPyUnsafeReflection flags importlib.import_module(...) when the
// module-name argument is itself tainted (request/env-derived, directly or
// through a local variable) — not gated on pyIsDynamicString: dynamic
// (but non-attacker-controlled) module names are a normal plugin-loading
// idiom, same reasoning as the other *-unsafe-reflection rules.
func checkPyUnsafeReflection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyImportModuleQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
arg := caps["arg"]
if !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-unsafe-reflection", "HIGH", path,
"Module imported by an attacker-controlled name",
"importlib.import_module(...) argument is derived from request/env input (directly, or through a local variable) — this imports whatever module name an attacker supplies, executing that module's top-level code",
caps["call"]))
}
return issues
}
var pyRandomSeedQuery = mustPyQuery(`(call function: (attribute object: (identifier) @mod attribute: (identifier) @fn) arguments: (argument_list . (integer)) (#eq? @mod "random") (#eq? @fn "seed")) @call`)
// checkPyPredictablePRNGSeed flags random.seed(<literal>) — a fixed seed
// makes every subsequent "random" value fully predictable, regardless of
// what the generator is later used for (distinct from
// py-insecure-random-for-secrets, which flags the module choice, not the
// seed). random.seed() with no args, or seeded from os.urandom()/similar,
// is unaffected — only a literal integer argument matches.
func checkPyPredictablePRNGSeed(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyRandomSeedQuery.ExecuteNode(root, pyLang, src) {
issues = append(issues, pyIssueAt("py-predictable-prng-seed", "MEDIUM", path,
"PRNG seeded with a hardcoded literal",
"random.seed(...) is called with a compile-time integer literal; every run produces the same sequence, making all subsequent output predictable",
capMap(m)["call"]))
}
return issues
}
var pySetCookieCallQuery = mustPyQuery(`(call function: (attribute attribute: (identifier) @meth) arguments: (argument_list) @args (#eq? @meth "set_cookie")) @call`)
func checkPyCookieMissingFlags(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pySetCookieCallQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
has := map[string]bool{}
for _, c := range caps["args"].Children() {
if c.Type(pyLang) != "keyword_argument" {
continue
}
name := c.ChildByFieldName("name", pyLang)
if name != nil {
has[strings.ToLower(string(name.Text(src)))] = true
}
}
for _, flag := range []string{"secure", "httponly"} {
if has[flag] {
continue
}
issues = append(issues, pyIssueAt("py-cookie-missing-flags", "LOW", path,
flag+" not set on set_cookie", "set_cookie(...) doesn't pass "+flag+"=...; it defaults to False, weakening cookie protection unless set elsewhere",
caps["call"]))
}
}
return issues
}
// pyAgentToolClasses are LangChain/AutoGen/CrewAI-style tools whose .run()
// executes their argument as code or a shell command with no sandbox --
// LangChain's own docs call several of these "unsafe" for exactly this
// reason. Curated by name since there's no import to gate on that would be
// reliable across langchain/langchain_community/langchain_experimental's
// churn between releases.
var pyAgentToolClasses = map[string]bool{
"PythonREPLTool": true, "PythonAstREPLTool": true, "ShellTool": true,
"BashProcess": true, "CodeInterpreterTool": true, "LocalCommandLineCodeExecutor": true,
}
// pyIsAgentToolConstructor matches both `PythonREPLTool().run(...)` directly
// and a variable previously assigned from one of pyAgentToolClasses'
// constructors -- same env-threading shape as pyExprTainted, just tracking
// "constructed from a dangerous class" instead of "tainted".
func pyIsAgentToolConstructor(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
switch n.Type(pyLang) {
case "identifier":
return env[string(n.Text(src))]
case "call":
fn := n.ChildByFieldName("function", pyLang)
if fn == nil {
return false
}
if fn.Type(pyLang) == "attribute" {
fn = fn.ChildByFieldName("attribute", pyLang)
}
return pyAgentToolClasses[string(fn.Text(src))]
default:
return false
}
}
var agentToolRunQuery = mustPyQuery(`(call function: (attribute object: (_) @obj attribute: (identifier) @meth) arguments: (argument_list . (_) @arg) (#any-of? @meth "run" "arun")) @call`)
func checkPyAgentUnsandboxedExec(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range agentToolRunQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
obj, arg := caps["obj"], caps["arg"]
body := tsEnclosingBody(obj, pyLang, pyFuncBoundary)
toolEnv := tsTaintEnv(body, pyLang, src, pyFuncBoundary, pyAssignInfo, pyIsAgentToolConstructor)
if !pyIsAgentToolConstructor(obj, pyLang, src, toolEnv) {
continue
}
if !pyIsDynamicString(arg, src) && !pyTaintedArg(arg, src) {
continue
}
issues = append(issues, pyIssueAt("py-agent-unsandboxed-exec", "HIGH", path,
"Agent tool executes untrusted input with no sandbox",
string(obj.Text(src))+"."+string(caps["meth"].Text(src))+"(...) runs the argument as code/a shell command; it traces back to request/env input with no sandboxing or allowlist in between",
caps["call"]))
}
return issues
}
// checkPyEmptyExceptionHandler flags a bare `except: pass`/`except X: pass`
// — Bandit's own B110 rule for exactly this shape, one of the most
// well-established anti-pattern lints in the Python ecosystem. Python has
// no syntax for a truly empty block (every suite needs at least one
// statement), so the signal here is a body that's exactly one `pass` and
// nothing else, not zero statements the way the other languages check.
// A body containing anything besides a lone `pass` (even a comment-only
// intent like `pass # deliberately ignored`) doesn't change this: `pass`
// is still the only statement, so it's still flagged — matches Bandit's
// own behavior, not a gap.
var pyExceptQuery = mustPyQuery(`(except_clause (block) @body) @ex`)
func checkPyEmptyExceptionHandler(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyExceptQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
body := caps["body"]
if body.NamedChildCount() != 1 || body.NamedChild(0).Type(pyLang) != "pass_statement" {
continue
}
issues = append(issues, pyIssueAt("py-empty-exception-handler", "MEDIUM", path,
"Empty exception handler", "except: pass silently swallows the exception, hiding real failures; at minimum log it",
caps["ex"]))
}
return issues
}
// checkPyUnreachableCode flags a statement immediately following a
// return/raise/break/continue in the same block — SonarQube's S1763.
var pyUnreachableQuery = mustPyQuery(`(block [(return_statement) (raise_statement) (break_statement) (continue_statement)] @term . (_) @after)`)
func checkPyUnreachableCode(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range pyUnreachableQuery.ExecuteNode(root, pyLang, src) {
caps := capMap(m)
issues = append(issues, pyIssueAt("py-unreachable-code", "LOW", path,
"Unreachable code", "this statement can never execute; it follows a "+string(caps["term"].Text(src))+" in the same block",
caps["after"]))
}
return issues
}
func capMap(m gts.QueryMatch) map[string]*gts.Node {
out := make(map[string]*gts.Node, len(m.Captures))
for _, c := range m.Captures {
out[c.Name] = c.Node
}
return out
}
package sast
import (
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/odvcencio/gotreesitter/grammars"
"github.com/colibrisec/ojo/internal/model"
)
var rubyLang = grammars.RubyLanguage()
func mustRubyQuery(src string) *gts.Query {
q, err := gts.NewQuery(src, rubyLang)
if err != nil {
panic("sast: invalid ruby query: " + err.Error())
}
return q
}
type rubyRule struct {
id string
severity string
check func(root *gts.Node, src []byte, path string) []model.Issue
}
var rubyRules = []rubyRule{
{"ruby-hardcoded-secret", "MEDIUM", checkRubyHardcodedSecret},
{"ruby-eval-detected", "HIGH", checkRubyEvalDetected},
{"ruby-command-injection", "HIGH", checkRubyCommandInjection},
{"ruby-sql-injection", "HIGH", checkRubySQLInjection},
{"ruby-weak-hash", "LOW", checkRubyWeakHash},
{"ruby-weak-cipher", "MEDIUM", checkRubyWeakCipher},
{"ruby-insecure-deserialization", "HIGH", checkRubyInsecureDeserialization},
{"ruby-insecure-random-for-secrets", "INFO", checkRubyInsecureRandom},
{"ruby-tls-verify-disabled", "HIGH", checkRubyTLSVerifyDisabled},
{"ruby-mass-assignment", "MEDIUM", checkRubyMassAssignment},
{"ruby-open-redirect", "MEDIUM", checkRubyOpenRedirect},
{"ruby-jwt-none-algorithm", "HIGH", checkRubyJWTNoneAlgorithm},
{"ruby-cors-wildcard", "MEDIUM", checkRubyCORSWildcard},
{"ruby-insecure-cookie", "MEDIUM", checkRubyInsecureCookie},
{"ruby-path-traversal", "HIGH", checkRubyPathTraversal},
{"ruby-cookie-missing-flags", "LOW", checkRubyCookieMissingFlags},
{"ruby-ssrf", "HIGH", checkRubySSRF},
{"ruby-xxe", "HIGH", checkRubyXXE},
{"ruby-ssti", "HIGH", checkRubySSTI},
{"ruby-unsafe-reflection", "HIGH", checkRubyUnsafeReflection},
{"ruby-predictable-prng-seed", "MEDIUM", checkRubyPredictablePRNGSeed},
{"ruby-empty-exception-handler", "MEDIUM", checkRubyEmptyExceptionHandler},
{"ruby-empty-block", "LOW", checkRubyEmptyBlock},
{"ruby-unreachable-code", "LOW", checkRubyUnreachableCode},
}
func rubyIssueAt(id, severity, path, title, message string, n *gts.Node) model.Issue {
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: int(n.StartPoint().Row) + 1,
Message: message,
CWEs: cweFor(id),
}
}
func rubyIsDynamicString(n *gts.Node, src []byte) bool {
switch n.Type(rubyLang) {
case "string", "subshell":
return hasDescendant(n, rubyLang, "interpolation")
case "binary":
op := n.ChildByFieldName("operator", rubyLang)
return op != nil && string(op.Text(src)) == "+"
default:
return false
}
}
func trimRubyQuotes(s string) string {
if len(s) >= 2 {
return s[1 : len(s)-1]
}
return s
}
var rubySecretAssignQuery = mustRubyQuery(`(assignment left: (identifier) @name right: (string) @val) @assign`)
func checkRubyHardcodedSecret(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubySecretAssignQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
name := string(caps["name"].Text(src))
val := caps["val"]
if !nameLooksSecret(name) || hasDescendant(val, rubyLang, "interpolation") {
continue
}
if len(trimRubyQuotes(string(val.Text(src)))) <= 4 {
continue
}
issues = append(issues, rubyIssueAt("ruby-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+name+" is assigned a literal string",
caps["assign"]))
}
return issues
}
var rubyEvalQuery = mustRubyQuery(`(call method: (identifier) @m (#eq? @m "eval")) @call`)
// rubyMetaEvalStringQuery matches instance_eval/class_eval/module_eval only
// in their string-argument form (`obj.class_eval("...")`), which executes
// the string as Ruby code — not their much more common block form
// (`klass.class_eval do ... end`), which is ordinary, safe metaprogramming
// used throughout idiomatic Ruby (Rails, RSpec, DSLs). Verified directly:
// the block form parses with a `block` field and no `arguments` field at
// all, while the string form has `arguments` and no `block` — requiring
// `arguments:` here is what keeps the block form out, not a name check.
var rubyMetaEvalStringQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (_) @arg) (#any-of? @m "instance_eval" "class_eval" "module_eval")) @call`)
func checkRubyEvalDetected(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyEvalQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-eval-detected", "HIGH", path,
"eval() used", "eval() executes arbitrary code; avoid it on any input that isn't fully trusted",
rubyCapMap(m)["call"]))
}
for _, m := range rubyMetaEvalStringQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
issues = append(issues, rubyIssueAt("ruby-eval-detected", "HIGH", path,
string(caps["m"].Text(src))+"() used with a string argument",
string(caps["m"].Text(src))+"(string) executes the string as Ruby code, just like eval(); avoid it on any input that isn't fully trusted (the block form, "+string(caps["m"].Text(src))+" do ... end, is unaffected — this only matches the string-argument call)",
caps["call"]))
}
return issues
}
var (
rubyCommandCallQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (_) @arg) (#any-of? @m "system" "exec" "spawn" "popen")) @call`)
rubySubshellQuery = mustRubyQuery(`(subshell (interpolation) @interp) @sub`)
)
func checkRubyCommandInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyCommandCallQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if !rubyIsDynamicString(caps["arg"], src) && !rubyTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-command-injection", "HIGH", path,
"Command built from a non-literal argument",
string(caps["m"].Text(src))+"(...) argument is built via string interpolation or `+` concatenation instead of a literal, or is a local variable derived from params/env input",
caps["call"]))
}
for _, m := range rubySubshellQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-command-injection", "HIGH", path,
"Backtick/%x command with interpolation", "a subshell command (`...`/%x{...}) interpolates a value; this runs through a shell and can be command injection if the value is attacker-influenced",
rubyCapMap(m)["sub"]))
}
return issues
}
var rubySQLCallQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (_) @arg) (#any-of? @m "where" "find_by_sql" "execute" "select_all" "select_one" "exec_query")) @call`)
func checkRubySQLInjection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubySQLCallQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if !rubyIsDynamicString(caps["arg"], src) && !rubyTaintedArg(caps["arg"], src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
string(caps["m"].Text(src))+"(...) query argument is built via string interpolation or concatenation instead of a bound parameter, or is a local variable derived from params/env input",
caps["call"]))
}
return issues
}
var rubyDigestQuery = mustRubyQuery(`(call receiver: (scope_resolution scope: (constant) @mod name: (constant) @alg) method: (identifier) @meth (#eq? @mod "Digest") (#any-of? @alg "MD5" "SHA1")) @call`)
func checkRubyWeakHash(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyDigestQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
alg := string(caps["alg"].Text(src))
issues = append(issues, rubyIssueAt("ruby-weak-hash", "LOW", path,
"Weak hash algorithm", "Digest::"+alg+" is cryptographically broken; use Digest::SHA256 or stronger",
caps["call"]))
}
return issues
}
var rubyCipherNewQuery = mustRubyQuery(`(call receiver: (scope_resolution) @recv method: (identifier) @meth arguments: (argument_list (string) @alg) (#eq? @recv "OpenSSL::Cipher") (#eq? @meth "new")) @call`)
// checkRubyWeakCipher flags OpenSSL::Cipher.new(...) with a broken cipher
// (DES/RC4) or an insecure mode (ECB) — same name-in-algorithm-string signal
// as go/java/js/php-weak-cipher, against Ruby's OpenSSL algorithm strings.
func checkRubyWeakCipher(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyCipherNewQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
alg := string(caps["alg"].Text(src))
upper := strings.ToUpper(alg)
if !strings.Contains(upper, "DES") && !strings.Contains(upper, "RC4") && !strings.Contains(upper, "ECB") {
continue
}
issues = append(issues, rubyIssueAt("ruby-weak-cipher", "MEDIUM", path,
"Weak cipher or insecure mode", "OpenSSL::Cipher.new("+alg+") uses a broken cipher or an insecure mode (ECB); use 'aes-256-gcm' instead",
caps["call"]))
}
return issues
}
var (
rubyMarshalLoadQuery = mustRubyQuery(`(call receiver: (constant) @recv method: (identifier) @meth (#eq? @recv "Marshal") (#eq? @meth "load")) @call`)
rubyYAMLLoadQuery = mustRubyQuery(`(call receiver: (constant) @recv method: (identifier) @meth (#eq? @recv "YAML") (#eq? @meth "load")) @call`)
)
func checkRubyInsecureDeserialization(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyMarshalLoadQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-insecure-deserialization", "HIGH", path,
"Insecure deserialization via Marshal.load", "Marshal.load can instantiate arbitrary objects and execute code when given untrusted data; use JSON for plain data",
rubyCapMap(m)["call"]))
}
for _, m := range rubyYAMLLoadQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-insecure-deserialization", "HIGH", path,
"Insecure deserialization via YAML.load", "YAML.load (as opposed to YAML.safe_load) can construct arbitrary Ruby objects from untrusted YAML",
rubyCapMap(m)["call"]))
}
return issues
}
var (
rubyRandCallQuery = mustRubyQuery(`(call method: (identifier) @m (#eq? @m "rand")) @call`)
rubyMethodDefQuery = mustRubyQuery(`(method name: (identifier) @fname body: (body_statement) @body) @def`)
// rubyFreeCallQuery captures an optional "recv" field precisely so a
// receiver-qualified call (obj.foo(x)) can be filtered out in Go code —
// Ruby's "call" node type is shared between free calls and
// receiver-qualified ones, verified directly before relying on this.
rubyFreeCallQuery = mustRubyQuery(`(call receiver: (_)? @recv method: (identifier) @fn arguments: (argument_list) @args) @call`)
)
func checkRubyInsecureRandom(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyMethodDefQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
fname := string(caps["fname"].Text(src))
if !nameLooksSecret(fname) && !strings.Contains(strings.ToLower(fname), "session") {
continue
}
for _, rm := range rubyRandCallQuery.ExecuteNode(caps["body"], rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-insecure-random-for-secrets", "INFO", path,
"rand() used in a security-sounding method",
"method "+fname+" uses Kernel#rand, which is not cryptographically secure; consider SecureRandom",
rubyCapMap(rm)["call"]))
}
}
return issues
}
var rubyVerifyNoneQuery = mustRubyQuery(`(scope_resolution) @scope (#eq? @scope "OpenSSL::SSL::VERIFY_NONE")`)
func checkRubyTLSVerifyDisabled(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyVerifyNoneQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-tls-verify-disabled", "HIGH", path,
"TLS certificate verification disabled", "OpenSSL::SSL::VERIFY_NONE disables certificate validation",
rubyCapMap(m)["scope"]))
}
return issues
}
var rubyPermitBangQuery = mustRubyQuery(`(call receiver: (identifier) @recv method: (identifier) @meth (#eq? @recv "params") (#eq? @meth "permit!")) @call`)
func checkRubyMassAssignment(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyPermitBangQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-mass-assignment", "MEDIUM", path,
"params.permit! bypasses strong parameters", "permit! whitelists every attribute in params, allowing mass assignment of any model attribute; list permitted keys explicitly instead",
rubyCapMap(m)["call"]))
}
return issues
}
var rubyRedirectToQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (_) @arg) (#eq? @m "redirect_to")) @call`)
func checkRubyOpenRedirect(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyRedirectToQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyIsDynamicString(arg, src) && !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-open-redirect", "MEDIUM", path,
"Redirect target built from request data", "redirect_to(...) argument is derived from params/request input (directly, or through a local variable) rather than a literal/allowlisted URL",
caps["call"]))
}
return issues
}
var rubyFuncBoundary = map[string]bool{"method": true, "singleton_method": true, "lambda": true, "block": true}
func rubyAssignInfo(n *gts.Node, lang *gts.Language, src []byte) (string, *gts.Node, bool) {
switch n.Type(rubyLang) {
case "assignment", "operator_assignment":
left := n.ChildByFieldName("left", rubyLang)
right := n.ChildByFieldName("right", rubyLang)
if left == nil || right == nil || left.Type(rubyLang) != "identifier" {
return "", nil, false
}
return string(left.Text(src)), right, true
default:
return "", nil, false
}
}
// rubyIsEnvSource matches ENV[...]/ENV.fetch(...) by raw text.
func rubyIsEnvSource(n *gts.Node, src []byte) bool {
text := string(n.Text(src))
return strings.HasPrefix(text, "ENV[") || strings.HasPrefix(text, "ENV.fetch(")
}
// rubyExprTainted reports whether n evaluates from tainted input: rooted
// at params/request (rubyRootedAtParams), an env-var read, a variable
// already known-tainted in env, or built from any of those via `+`
// concatenation, string interpolation, or a call's arguments.
func rubyExprTainted(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool {
if n == nil {
return false
}
if rubyRootedAtParams(n, src) || rubyIsEnvSource(n, src) {
return true
}
switch n.Type(rubyLang) {
case "identifier":
return env[string(n.Text(src))]
case "binary":
op := n.ChildByFieldName("operator", rubyLang)
if op == nil || string(op.Text(src)) != "+" {
return false
}
return rubyExprTainted(n.ChildByFieldName("left", rubyLang), lang, src, env) || rubyExprTainted(n.ChildByFieldName("right", rubyLang), lang, src, env)
case "string":
for _, c := range n.Children() {
if c.Type(rubyLang) != "interpolation" || c.NamedChildCount() == 0 {
continue
}
if rubyExprTainted(c.NamedChild(0), lang, src, env) {
return true
}
}
return false
case "call":
args := n.ChildByFieldName("arguments", rubyLang)
if args == nil {
return false
}
for _, a := range args.Children() {
if rubyExprTainted(a, lang, src, env) {
return true
}
}
return false
default:
return false
}
}
// rubyTaintedArg reports whether arg evaluates from tainted input, tracking
// through local variable assignments within its enclosing method/lambda/
// block (intraprocedural taint tracking — see taint_ts.go).
func rubyTaintedArg(arg *gts.Node, src []byte) bool {
body := tsEnclosingBody(arg, rubyLang, rubyFuncBoundary)
env := tsTaintEnv(body, rubyLang, src, rubyFuncBoundary, rubyAssignInfo, rubyExprTainted)
return rubyExprTainted(arg, rubyLang, src, env)
}
func rubyRootedAtParams(n *gts.Node, src []byte) bool {
for {
switch n.Type(rubyLang) {
case "element_reference":
n = n.ChildByFieldName("object", rubyLang)
case "call":
recv := n.ChildByFieldName("receiver", rubyLang)
if recv == nil {
return false
}
n = recv
default:
if n.Type(rubyLang) != "identifier" {
return false
}
name := string(n.Text(src))
return name == "params" || name == "request"
}
if n == nil {
return false
}
}
}
var (
rubyJWTPairQuery = mustRubyQuery(`(pair key: (hash_key_symbol) @key value: (string) @val) @pair`)
rubyJWTEncodeQuery = mustRubyQuery(`(call receiver: (constant) @recv method: (identifier) @meth arguments: (argument_list (_) (_) (string) @alg) (#eq? @recv "JWT") (#eq? @meth "encode")) @call`)
)
func checkRubyJWTNoneAlgorithm(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyJWTPairQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if string(caps["key"].Text(src)) != "alg" || trimRubyQuotes(string(caps["val"].Text(src))) != "none" {
continue
}
issues = append(issues, rubyIssueAt("ruby-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "alg: 'none' accepts unsigned tokens, allowing signature bypass",
caps["pair"]))
}
for _, m := range rubyJWTEncodeQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if trimRubyQuotes(string(caps["alg"].Text(src))) != "none" {
continue
}
issues = append(issues, rubyIssueAt("ruby-jwt-none-algorithm", "HIGH", path,
"JWT algorithm set to 'none'", "JWT.encode(..., 'none') accepts unsigned tokens, allowing signature bypass",
caps["call"]))
}
return issues
}
var rubyHeaderAssignQuery = mustRubyQuery(`(assignment left: (element_reference object: (_) @recv (string) @key) right: (string) @val) @assign`)
func checkRubyCORSWildcard(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyHeaderAssignQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if !strings.Contains(strings.ToLower(string(caps["recv"].Text(src))), "headers") {
continue
}
key := trimRubyQuotes(string(caps["key"].Text(src)))
val := trimRubyQuotes(string(caps["val"].Text(src)))
if !strings.EqualFold(key, "Access-Control-Allow-Origin") || val != "*" {
continue
}
issues = append(issues, rubyIssueAt("ruby-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard", "headers['Access-Control-Allow-Origin'] = '*' allows any origin to make credentialed cross-origin requests",
caps["assign"]))
}
return issues
}
var rubyCookieBoolPairQuery = mustRubyQuery(`(pair key: (hash_key_symbol) @key value: (false)) @pair`)
func checkRubyInsecureCookie(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyCookieBoolPairQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
key := strings.ToLower(string(caps["key"].Text(src)))
if key != "secure" && key != "httponly" {
continue
}
issues = append(issues, rubyIssueAt("ruby-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", key+": false weakens cookie protection",
caps["pair"]))
}
return issues
}
var rubyFileOpenQuery = mustRubyQuery(`(call receiver: (constant) @recv method: (identifier) @meth arguments: (argument_list . (_) @arg) (#eq? @recv "File") (#any-of? @meth "open" "read")) @call`)
func checkRubyPathTraversal(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyFileOpenQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyIsDynamicString(arg, src) && !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-path-traversal", "HIGH", path,
"File path built from request data", "File."+string(caps["meth"].Text(src))+"(...) path is derived from params/request input (directly, or through a local variable) or built via interpolation/concatenation rather than a validated literal; sanitize/allowlist before use",
caps["call"]))
}
return issues
}
var (
rubyNetHTTPGetQuery = mustRubyQuery(`(call receiver: (scope_resolution scope: (constant) @mod name: (constant) @cls) method: (identifier) @meth arguments: (argument_list . (_) @arg) (#eq? @mod "Net") (#eq? @cls "HTTP") (#eq? @meth "get")) @call`)
rubyURIOpenQuery = mustRubyQuery(`(call receiver: (constant) @mod method: (identifier) @meth arguments: (argument_list . (_) @arg) (#eq? @mod "URI") (#eq? @meth "open")) @call`)
)
func checkRubySSRF(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyNetHTTPGetQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyIsDynamicString(arg, src) && !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"Net::HTTP.get(...) URL argument is derived from params/env input (directly, or through a local variable) or built via interpolation/concatenation rather than a validated/allowlisted URL",
caps["call"]))
}
for _, m := range rubyURIOpenQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyIsDynamicString(arg, src) && !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-ssrf", "HIGH", path,
"Outbound request URL built from request data",
"URI.open(...) URL argument is derived from params/env input (directly, or through a local variable) or built via interpolation/concatenation rather than a validated/allowlisted URL",
caps["call"]))
}
return issues
}
var (
rubyNoentConstQuery = mustRubyQuery(`(scope_resolution name: (constant) @c (#eq? @c "NOENT")) @ref`)
rubyNoentCallQuery = mustRubyQuery(`(call method: (identifier) @m (#eq? @m "noent")) @call`)
)
func checkRubyXXE(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyNoentConstQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-xxe", "HIGH", path,
"XML entity substitution enabled", "Nokogiri::XML::ParseOptions::NOENT enables entity substitution, allowing XXE when parsing untrusted XML",
rubyCapMap(m)["ref"]))
}
for _, m := range rubyNoentCallQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-xxe", "HIGH", path,
"XML entity substitution enabled", "config.noent enables entity substitution in a Nokogiri parse-options block, allowing XXE when parsing untrusted XML",
rubyCapMap(m)["call"]))
}
return issues
}
var rubyERBNewQuery = mustRubyQuery(`(call receiver: (constant) @mod method: (identifier) @meth arguments: (argument_list . (_) @arg) (#eq? @mod "ERB") (#eq? @meth "new")) @call`)
func checkRubySSTI(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyERBNewQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyIsDynamicString(arg, src) && !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-ssti", "HIGH", path,
"Template source built from request data",
"ERB.new(...) argument is derived from params/env input (directly, or through a local variable) or built via interpolation/concatenation — the template source itself is attacker-controlled, which is server-side template injection, not just a data-substitution issue",
caps["call"]))
}
return issues
}
var rubySendQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (_) @arg) (#any-of? @m "send" "public_send" "__send__")) @call`)
// checkRubyUnsafeReflection flags .send/.public_send/.__send__ when the
// method-name argument is itself tainted (request/env-derived, directly or
// through a local variable) — arbitrary method invocation, a well-known
// Ruby/Rails RCE-adjacent gadget class. Not gated on rubyIsDynamicString:
// the overwhelmingly common, safe usage is a literal symbol
// (`obj.send(:foo)`), which never matches either dynamic-string or taint
// shape, so only the taint check is needed and it stays quiet on that idiom.
func checkRubyUnsafeReflection(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubySendQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
arg := caps["arg"]
if !rubyTaintedArg(arg, src) {
continue
}
issues = append(issues, rubyIssueAt("ruby-unsafe-reflection", "HIGH", path,
"Method invoked by an attacker-controlled name",
string(caps["m"].Text(src))+"(...) method-name argument is derived from params/env input (directly, or through a local variable) — this calls whatever method name an attacker supplies, letting them invoke methods the application never intended to expose",
caps["call"]))
}
return issues
}
var rubySrandQuery = mustRubyQuery(`(call method: (identifier) @m arguments: (argument_list . (integer)) (#eq? @m "srand")) @call`)
// checkRubyPredictablePRNGSeed flags srand(<literal>) — a fixed seed makes
// every subsequent Kernel#rand value fully predictable (distinct from
// ruby-insecure-random-for-secrets, which flags the module choice, not the
// seed). srand() with no args is unaffected.
func checkRubyPredictablePRNGSeed(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubySrandQuery.ExecuteNode(root, rubyLang, src) {
issues = append(issues, rubyIssueAt("ruby-predictable-prng-seed", "MEDIUM", path,
"PRNG seeded with a hardcoded literal",
"srand(...) is called with a compile-time integer literal; every run produces the same sequence, making all subsequent output predictable",
rubyCapMap(m)["call"]))
}
return issues
}
var rubyCookieAssignQuery = mustRubyQuery(`(assignment left: (element_reference object: (_) @recv) right: (_) @val) @assign`)
func checkRubyCookieMissingFlags(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyCookieAssignQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if !strings.Contains(strings.ToLower(string(caps["recv"].Text(src))), "cookies") {
continue
}
val := caps["val"]
if val.Type(rubyLang) != "hash" {
issues = append(issues, rubyIssueAt("ruby-cookie-missing-flags", "LOW", path,
"Cookie set without secure/httponly options", "cookies[...] = ... assigns a plain value instead of a hash with secure/httponly options; both default to false, weakening cookie protection",
caps["assign"]))
continue
}
has := map[string]bool{}
for _, c := range val.Children() {
if c.Type(rubyLang) != "pair" {
continue
}
key := c.ChildByFieldName("key", rubyLang)
if key != nil {
has[strings.ToLower(string(key.Text(src)))] = true
}
}
for _, flag := range []string{"secure", "httponly"} {
if has[flag] {
continue
}
issues = append(issues, rubyIssueAt("ruby-cookie-missing-flags", "LOW", path,
flag+" not set on cookie options", "cookies[...] = {...} doesn't set "+flag+"; it defaults to false, weakening cookie protection unless set elsewhere",
val))
}
}
return issues
}
// rubyHasChildType reports whether n has a direct named child of the given
// type — used below since Ruby's grammar represents an empty `then`/`do`
// branch by omitting the wrapper node entirely rather than emitting one
// with zero children (unlike Java/JS/PHP's if/while, which always emit a
// block node even when it's empty). Verified against a real parse tree
// before relying on this: `rescue => e\nend` (empty) has only an
// exception_variable child; `if x\nend` (empty) has only the condition
// child — in both cases the body wrapper node ("then") simply isn't there.
func rubyHasChildType(n *gts.Node, typ string) bool {
for _, c := range n.Children() {
if c.Type(rubyLang) == typ {
return true
}
}
return false
}
// checkRubyEmptyExceptionHandler flags a `rescue` clause with no body at
// all — SonarQube's S2486/S1166 shape, RuboCop's own Lint/SuppressedException
// cop covers the identical case. A rescue that does anything (even just
// `rescue => e; end` with a body of, say, a single no-op call) is not
// flagged; only a rescue with nothing between `rescue`/`rescue => e` and
// `end` is.
var rubyRescueQuery = mustRubyQuery(`(rescue) @r`)
func checkRubyEmptyExceptionHandler(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyRescueQuery.ExecuteNode(root, rubyLang, src) {
r := rubyCapMap(m)["r"]
if rubyHasChildType(r, "then") {
continue
}
issues = append(issues, rubyIssueAt("ruby-empty-exception-handler", "MEDIUM", path,
"Empty rescue clause", "rescue with no body silently swallows the exception, hiding real failures; at minimum log it",
r))
}
return issues
}
// checkRubyEmptyBlock flags an if/else/while body with no statements at all
// (SonarQube's S108) — likely dead code, or (in the if-branch case) a
// silently-swallowed condition. Ruby's `unless`/`until` get the same
// treatment as Go's skip of switch/select: not covered this round, same
// shape as `if`/`while` if this rule set is revisited.
var (
rubyIfQuery = mustRubyQuery(`(if) @if`)
rubyIfElseQuery = mustRubyQuery(`(if (else) @body) @if`)
rubyWhileQuery = mustRubyQuery(`(while (do) @body) @w`)
)
func checkRubyEmptyBlock(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, m := range rubyIfQuery.ExecuteNode(root, rubyLang, src) {
ifNode := rubyCapMap(m)["if"]
if rubyHasChildType(ifNode, "then") {
continue
}
issues = append(issues, rubyIssueAt("ruby-empty-block", "LOW", path,
"Empty if block", "if body has no statements — likely dead code, or (if this is an error check) a silently-swallowed condition",
ifNode))
}
for _, m := range rubyIfElseQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, rubyIssueAt("ruby-empty-block", "LOW", path,
"Empty else block", "else body has no statements — likely dead code",
caps["if"]))
}
for _, m := range rubyWhileQuery.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
if caps["body"].NamedChildCount() > 0 {
continue
}
issues = append(issues, rubyIssueAt("ruby-empty-block", "LOW", path,
"Empty while block", "while body has no statements — likely dead code",
caps["w"]))
}
return issues
}
// checkRubyUnreachableCode flags a statement immediately following a
// return/break/next in the same `then` (if/rescue body) or method/block
// body — SonarQube's S1763. Ruby has no `raise` statement type (`raise` is
// an ordinary Kernel method call, indistinguishable at the grammar level
// from any other call), so unlike the other languages this can't include
// raise; and `while`/`until`'s `do`-wrapped body isn't covered this round
// (the two containers here — `then` and `body_statement` — cover the
// dominant real-world case: a guard clause or rescue ending a method or
// iterator block early).
var (
rubyUnreachableThenQuery = mustRubyQuery(`(then [(return) (break) (next)] @term . (_) @after)`)
rubyUnreachableBodyQuery = mustRubyQuery(`(body_statement [(return) (break) (next)] @term . (_) @after)`)
)
func checkRubyUnreachableCode(root *gts.Node, src []byte, path string) []model.Issue {
var issues []model.Issue
for _, q := range []*gts.Query{rubyUnreachableThenQuery, rubyUnreachableBodyQuery} {
for _, m := range q.ExecuteNode(root, rubyLang, src) {
caps := rubyCapMap(m)
issues = append(issues, rubyIssueAt("ruby-unreachable-code", "LOW", path,
"Unreachable code", "this statement can never execute; it follows a "+string(caps["term"].Text(src))+" in the same block",
caps["after"]))
}
}
return issues
}
func rubyCapMap(m gts.QueryMatch) map[string]*gts.Node {
out := make(map[string]*gts.Node, len(m.Captures))
for _, c := range m.Captures {
out[c.Name] = c.Node
}
return out
}
package sast
import (
"go/ast"
"go/token"
"strconv"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
func importedAs(f *ast.File, path string) (string, bool) {
for _, imp := range f.Imports {
p, _ := strconv.Unquote(imp.Path.Value)
if p != path {
continue
}
if imp.Name != nil {
return imp.Name.Name, true
}
parts := strings.Split(path, "/")
return parts[len(parts)-1], true
}
return "", false
}
func isDynamicString(e ast.Expr) bool {
switch v := e.(type) {
case *ast.BasicLit:
return false
case *ast.BinaryExpr:
return v.Op == token.ADD
case *ast.CallExpr:
if sel, ok := v.Fun.(*ast.SelectorExpr); ok {
if id, ok := sel.X.(*ast.Ident); ok && id.Name == "fmt" &&
(sel.Sel.Name == "Sprintf" || sel.Sel.Name == "Sprint") {
return true
}
}
return false
default:
return false
}
}
var secretNameKeywords = []string{"password", "passwd", "secret", "apikey", "api_key", "token"}
func nameLooksSecret(name string) bool {
lower := strings.ToLower(name)
for _, kw := range secretNameKeywords {
if strings.Contains(lower, kw) {
return true
}
}
return false
}
func checkHardcodedSecret(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.AssignStmt:
for i, lhs := range v.Lhs {
id, ok := lhs.(*ast.Ident)
if !ok || i >= len(v.Rhs) {
continue
}
lit, ok := v.Rhs[i].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
if nameLooksSecret(id.Name) && litLen(lit) > 4 {
issues = append(issues, issueAt("go-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+id.Name+" is assigned a literal string",
fset, lit.Pos()))
}
}
case *ast.ValueSpec:
for i, id := range v.Names {
if i >= len(v.Values) {
continue
}
lit, ok := v.Values[i].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
if nameLooksSecret(id.Name) && litLen(lit) > 4 {
issues = append(issues, issueAt("go-hardcoded-secret", "MEDIUM", path,
"Hardcoded secret-looking value", "variable "+id.Name+" is assigned a literal string",
fset, lit.Pos()))
}
}
}
return true
})
return issues
}
func litLen(lit *ast.BasicLit) int {
s, err := strconv.Unquote(lit.Value)
if err != nil {
return len(lit.Value)
}
return len(s)
}
func checkCommandInjection(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "os/exec")
if !ok {
return nil
}
var issues []model.Issue
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg || (sel.Sel.Name != "Command" && sel.Sel.Name != "CommandContext") {
return true
}
args := call.Args
if sel.Sel.Name == "CommandContext" && len(args) > 0 {
args = args[1:] // first arg is context.Context
}
for _, a := range args {
if isDynamicString(a) || goExprTainted(a, env) {
issues = append(issues, issueAt("go-command-injection", "HIGH", path,
"Command built from a non-literal argument",
pkg+"."+sel.Sel.Name+" argument is not a string literal (Sprintf/concatenation, or a local variable derived from request/env input)",
fset, call.Pos()))
break
}
}
return true
})
})
return issues
}
func checkSQLInjection(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
sqlMethods := map[string]bool{"Query": true, "QueryContext": true, "QueryRow": true, "QueryRowContext": true, "Exec": true, "ExecContext": true}
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !sqlMethods[sel.Sel.Name] || len(call.Args) == 0 {
return true
}
queryArg := call.Args[0]
if _, isCtx := queryArg.(*ast.SelectorExpr); isCtx && len(call.Args) > 1 {
queryArg = call.Args[1] // *Context variants take ctx first
}
if isDynamicString(queryArg) || goExprTainted(queryArg, env) {
issues = append(issues, issueAt("go-sql-injection", "HIGH", path,
"SQL query built from a non-literal string",
sel.Sel.Name+" query argument is built via Sprintf/concatenation instead of placeholders, or is a local variable derived from request/env input",
fset, call.Pos()))
}
return true
})
})
return issues
}
func checkWeakHash(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
for _, weak := range []string{"crypto/md5", "crypto/sha1"} {
pkg, ok := importedAs(f, weak)
if !ok {
continue
}
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
if id, ok := sel.X.(*ast.Ident); ok && id.Name == pkg {
issues = append(issues, issueAt("go-weak-hash", "LOW", path,
"Weak hash algorithm", weak+" is cryptographically broken; use crypto/sha256 or stronger",
fset, sel.Pos()))
}
return true
})
}
return issues
}
func checkWeakDES(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "crypto/des")
if !ok {
return nil
}
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
if id, ok := sel.X.(*ast.Ident); ok && id.Name == pkg {
issues = append(issues, issueAt("go-weak-cipher-des", "MEDIUM", path,
"Weak cipher DES", "crypto/des is a weak cipher; use crypto/aes",
fset, sel.Pos()))
}
return true
})
return issues
}
func checkInsecureRandom(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "math/rand")
if !ok {
return nil
}
var issues []model.Issue
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil || !nameLooksSecret(fn.Name.Name) && !strings.Contains(strings.ToLower(fn.Name.Name), "session") {
continue
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if ok {
if id, ok := sel.X.(*ast.Ident); ok && id.Name == pkg {
issues = append(issues, issueAt("go-insecure-random-for-secrets", "INFO", path,
"math/rand used in a security-sounding function",
"function "+fn.Name.Name+" uses math/rand, which is not cryptographically secure; consider crypto/rand",
fset, sel.Pos()))
}
}
return true
})
}
return issues
}
var authCallNames = map[string]bool{"Verify": true, "Authenticate": true, "CompareHashAndPassword": true}
func checkDiscardedAuthError(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
stmt, ok := n.(*ast.ExprStmt)
if !ok {
return true
}
call, ok := stmt.X.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || !authCallNames[sel.Sel.Name] {
return true
}
issues = append(issues, issueAt("go-discarded-auth-error", "HIGH", path,
"Auth call result discarded", "return value of "+sel.Sel.Name+" (likely an error) is not checked",
fset, call.Pos()))
return true
})
return issues
}
func checkTLSInsecureSkipVerify(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Config" {
return true
}
if id, ok := sel.X.(*ast.Ident); !ok || id.Name != "tls" {
return true
}
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != "InsecureSkipVerify" {
continue
}
if val, ok := kv.Value.(*ast.Ident); ok && val.Name == "true" {
issues = append(issues, issueAt("go-tls-insecure-skip-verify", "HIGH", path,
"TLS certificate verification disabled", "tls.Config{InsecureSkipVerify: true} disables certificate validation",
fset, kv.Pos()))
}
}
return true
})
return issues
}
var (
permissiveFileModes = map[string]bool{"0777": true, "0666": true, "0o777": true, "0o666": true}
fileModeFuncs = map[string]bool{"OpenFile": true, "MkdirAll": true, "Mkdir": true, "Chmod": true}
)
func checkPermissiveFileMode(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "os")
if !ok {
return nil
}
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg || !fileModeFuncs[sel.Sel.Name] {
return true
}
for _, a := range call.Args {
lit, ok := a.(*ast.BasicLit)
if ok && lit.Kind == token.INT && permissiveFileModes[lit.Value] {
issues = append(issues, issueAt("go-permissive-file-mode", "MEDIUM", path,
"World-writable file mode", sel.Sel.Name+" called with mode "+lit.Value+" (world-writable)",
fset, call.Pos()))
}
}
return true
})
return issues
}
// rootedAtRequest reports whether e is a selector/call chain rooted at an
// identifier commonly used for the incoming *http.Request (r, req, request) —
// e.g. r.FormValue("next") or r.URL.Query().Get("next").
func goRootedAtRequest(e ast.Expr) bool {
for {
switch v := e.(type) {
case *ast.SelectorExpr:
e = v.X
case *ast.CallExpr:
e = v.Fun
case *ast.IndexExpr:
e = v.X
case *ast.Ident:
return v.Name == "r" || v.Name == "req" || v.Name == "request"
default:
return false
}
}
}
func checkOpenRedirect(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "net/http")
if !ok {
return nil
}
var issues []model.Issue
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg || sel.Sel.Name != "Redirect" || len(call.Args) < 3 {
return true
}
target := call.Args[2]
if !isDynamicString(target) && !goExprTainted(target, env) {
return true
}
issues = append(issues, issueAt("go-open-redirect", "MEDIUM", path,
"Redirect target built from request data",
"http.Redirect target is derived from request input (directly or through a local variable) or built via Sprintf/concatenation rather than a literal/allowlisted URL",
fset, call.Pos()))
return true
})
})
return issues
}
func checkJWTNoneAlgorithm(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "SigningMethodNone" {
return true
}
issues = append(issues, issueAt("go-jwt-none-algorithm", "HIGH", path,
"JWT signing method set to none", "jwt.SigningMethodNone accepts unsigned tokens, allowing signature bypass",
fset, sel.Pos()))
return true
})
return issues
}
func checkCORSWildcard(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Set" || len(call.Args) != 2 {
return true
}
key, ok := call.Args[0].(*ast.BasicLit)
if !ok || key.Kind != token.STRING {
return true
}
k, err := strconv.Unquote(key.Value)
if err != nil || !strings.EqualFold(k, "Access-Control-Allow-Origin") {
return true
}
val, ok := call.Args[1].(*ast.BasicLit)
if !ok || val.Kind != token.STRING {
return true
}
v, err := strconv.Unquote(val.Value)
if err != nil || v != "*" {
return true
}
issues = append(issues, issueAt("go-cors-wildcard", "MEDIUM", path,
"CORS allow-origin set to wildcard",
`Header().Set("Access-Control-Allow-Origin", "*") allows any origin to make credentialed cross-origin requests`,
fset, call.Pos()))
return true
})
return issues
}
func checkInsecureCookie(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "net/http")
if !ok {
return nil
}
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Cookie" {
return true
}
if id, ok := sel.X.(*ast.Ident); !ok || id.Name != pkg {
return true
}
secureTrue := false
var sameSiteNone ast.Expr
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok {
continue
}
switch key.Name {
case "Secure", "HttpOnly":
if val, ok := kv.Value.(*ast.Ident); ok && val.Name == "false" {
issues = append(issues, issueAt("go-insecure-cookie", "MEDIUM", path,
"Cookie flag explicitly disabled", "http.Cookie{"+key.Name+": false} weakens cookie protection ("+key.Name+" should normally be true)",
fset, kv.Pos()))
}
if key.Name == "Secure" {
if val, ok := kv.Value.(*ast.Ident); ok && val.Name == "true" {
secureTrue = true
}
}
case "SameSite":
if valSel, ok := kv.Value.(*ast.SelectorExpr); ok && valSel.Sel.Name == "SameSiteNoneMode" {
sameSiteNone = kv.Value
}
}
}
// SameSite=None requires Secure — checked after the full literal is
// scanned since Secure/SameSite can appear in either order.
if sameSiteNone != nil && !secureTrue {
issues = append(issues, issueAt("go-insecure-cookie", "MEDIUM", path,
"SameSite=None cookie without Secure",
"http.Cookie{SameSite: http.SameSiteNoneMode} is set without Secure: true in the same literal — SameSite=None requires Secure or modern browsers reject the cookie outright, and without Secure the cookie is also sent over plain HTTP",
fset, sameSiteNone.Pos()))
}
return true
})
return issues
}
var pathTraversalFuncs = map[string]bool{"Open": true, "ReadFile": true, "Create": true}
func checkPathTraversal(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "os")
if !ok {
return nil
}
var issues []model.Issue
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg || !pathTraversalFuncs[sel.Sel.Name] || len(call.Args) == 0 {
return true
}
arg := call.Args[0]
if !isDynamicString(arg) && !goExprTainted(arg, env) {
return true
}
issues = append(issues, issueAt("go-path-traversal", "HIGH", path,
"File path built from request data",
"os."+sel.Sel.Name+" path is derived from request input (directly or through a local variable) or built via Sprintf/concatenation rather than a validated literal; sanitize/allowlist before use",
fset, call.Pos()))
return true
})
})
return issues
}
var httpDirectSSRFFuncs = map[string]bool{"Get": true, "Post": true, "Head": true, "PostForm": true}
// checkSSRF flags an outbound HTTP request whose URL is built from
// request/env data: net/http's package-level Get/Post/Head/PostForm (URL is
// the first argument) and NewRequest/NewRequestWithContext (URL is the
// method/URL-taking argument, not the leading context.Context).
func checkSSRF(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "net/http")
if !ok {
return nil
}
var issues []model.Issue
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg {
return true
}
var urlArg ast.Expr
switch {
case httpDirectSSRFFuncs[sel.Sel.Name] && len(call.Args) > 0:
urlArg = call.Args[0]
case sel.Sel.Name == "NewRequest" && len(call.Args) >= 2:
urlArg = call.Args[1]
case sel.Sel.Name == "NewRequestWithContext" && len(call.Args) >= 3:
urlArg = call.Args[2]
default:
return true
}
if !isDynamicString(urlArg) && !goExprTainted(urlArg, env) {
return true
}
issues = append(issues, issueAt("go-ssrf", "HIGH", path,
"Outbound request URL built from request data",
pkg+"."+sel.Sel.Name+" URL argument is built via Sprintf/concatenation, or is a local variable derived from request/env input, rather than a validated/allowlisted URL",
fset, call.Pos()))
return true
})
})
return issues
}
// checkSSTI flags text/template or html/template's New(...).Parse(...) chain
// when the template source itself (not just the data rendered into it) is
// built from request/env data — the template source being attacker-
// controlled is server-side template injection, not just a substitution
// bug. Scoped to the exact New(...).Parse(...) chain (not a bare .Parse(
// method name, which collides with time.Parse/url.Parse/flag.Parse and
// would be a false-positive magnet) rooted at the actually-imported
// package name.
func checkSSTI(f *ast.File, fset *token.FileSet, path string) []model.Issue {
textPkg, textOK := importedAs(f, "text/template")
htmlPkg, htmlOK := importedAs(f, "html/template")
if !textOK && !htmlOK {
return nil
}
var issues []model.Issue
forEachGoFuncBody(f, func(body *ast.BlockStmt) {
env := goTaintEnv(body)
inspectWithinFunc(body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Parse" || len(call.Args) == 0 {
return true
}
inner, ok := sel.X.(*ast.CallExpr)
if !ok {
return true
}
innerSel, ok := inner.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := innerSel.X.(*ast.Ident)
if !ok || innerSel.Sel.Name != "New" || (id.Name != textPkg && id.Name != htmlPkg) {
return true
}
arg := call.Args[0]
if !isDynamicString(arg) && !goExprTainted(arg, env) {
return true
}
issues = append(issues, issueAt("go-ssti", "HIGH", path,
"Template source built from request data",
id.Name+".New(...).Parse(...) argument is built via Sprintf/concatenation, or is a local variable derived from request/env input — the template source itself is attacker-controlled, which is server-side template injection, not just a data-substitution issue",
fset, call.Pos()))
return true
})
})
return issues
}
// checkPredictablePRNGSeed flags math/rand's Seed(...)/NewSource(...) called
// with a compile-time integer literal — a fixed seed makes every subsequent
// "random" value fully predictable, regardless of what the generator is
// later used for (a distinct anti-pattern from go-insecure-random-for-secrets,
// which flags the algorithm choice, not the seed).
func checkPredictablePRNGSeed(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "math/rand")
if !ok {
return nil
}
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if !ok || id.Name != pkg || (sel.Sel.Name != "Seed" && sel.Sel.Name != "NewSource") || len(call.Args) == 0 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.INT {
return true
}
issues = append(issues, issueAt("go-predictable-prng-seed", "MEDIUM", path,
"PRNG seeded with a hardcoded literal",
pkg+"."+sel.Sel.Name+" is called with a compile-time integer literal; every run produces the same sequence, making all subsequent output predictable — seed from crypto/rand or leave unseeded (math/rand auto-seeds since Go 1.20)",
fset, call.Pos()))
return true
})
return issues
}
func checkCookieMissingFlags(f *ast.File, fset *token.FileSet, path string) []model.Issue {
pkg, ok := importedAs(f, "net/http")
if !ok {
return nil
}
var issues []model.Issue
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.CompositeLit)
if !ok {
return true
}
sel, ok := lit.Type.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Cookie" {
return true
}
if id, ok := sel.X.(*ast.Ident); !ok || id.Name != pkg {
return true
}
has := map[string]bool{}
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
if key, ok := kv.Key.(*ast.Ident); ok {
has[key.Name] = true
}
}
for _, field := range []string{"Secure", "HttpOnly"} {
if has[field] {
continue
}
issues = append(issues, issueAt("go-cookie-missing-flags", "LOW", path,
field+" not set on http.Cookie", "http.Cookie{...} doesn't set "+field+"; it defaults to false, weakening cookie protection unless set elsewhere",
fset, lit.Pos()))
}
return true
})
return issues
}
// checkEmptyBlock is ojo's first reliability ("Bug", not "Vulnerability")
// rule, the SonarQube-style category ojo previously had zero coverage for.
// It flags an if/else/for/range body with no statements at all — almost
// always either dead code left over from refactoring or, in the most common
// real case, a silently-swallowed error check (`if err != nil { }`). An
// empty function body is deliberately not flagged: unlike a branch, an
// empty function/method is a completely ordinary stub/interface
// implementation, not a bug candidate.
//
// go-unreachable-code (the statement-after-return/break/continue/panic shape
// this rule's sibling in every other language covers) is deliberately not
// added for Go: `go vet`'s unreachable analyzer already covers this
// natively and is already part of any real Go CI pipeline — duplicating it
// here would just be a worse copy of a check the toolchain already ships.
func checkEmptyBlock(f *ast.File, fset *token.FileSet, path string) []model.Issue {
var issues []model.Issue
flag := func(pos token.Pos, shape string) {
issues = append(issues, issueAt("go-empty-block", "LOW", path,
"Empty "+shape+" block", shape+" body has no statements — likely dead code, or (if this is an error check) a silently-swallowed error",
fset, pos))
}
ast.Inspect(f, func(n ast.Node) bool {
switch s := n.(type) {
case *ast.IfStmt:
if len(s.Body.List) == 0 {
flag(s.Body.Lbrace, "if")
}
if elseBlock, ok := s.Else.(*ast.BlockStmt); ok && len(elseBlock.List) == 0 {
flag(elseBlock.Lbrace, "else")
}
case *ast.ForStmt:
if len(s.Body.List) == 0 {
flag(s.Body.Lbrace, "for")
}
case *ast.RangeStmt:
if len(s.Body.List) == 0 {
flag(s.Body.Lbrace, "for-range")
}
}
return true
})
return issues
}
// Package sast statically analyzes source for common security anti-patterns:
// Go via the standard library's go/ast, Python via gotreesitter (a pure-Go,
// no-cgo tree-sitter runtime — see docs/guide/scanner/sast.md for why cgo
// was avoided) and its query engine.
//
// ponytail ceiling: pattern-matching over syntax only. No taint tracking, no
// interprocedural analysis, no cross-file resolution — each rule is a
// bespoke predicate/query, not a general query language. Expect false
// positives on the "unsanitized input" rules; treat findings as candidates
// for human triage, not proven vulnerabilities.
package sast
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"os"
"strings"
gts "github.com/odvcencio/gotreesitter"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
type rule struct {
id string
severity string
check func(f *ast.File, fset *token.FileSet, path string) []model.Issue
}
var rules = []rule{
{"go-hardcoded-secret", "MEDIUM", checkHardcodedSecret},
{"go-command-injection", "HIGH", checkCommandInjection},
{"go-sql-injection", "HIGH", checkSQLInjection},
{"go-weak-hash", "LOW", checkWeakHash},
{"go-weak-cipher-des", "MEDIUM", checkWeakDES},
{"go-insecure-random-for-secrets", "INFO", checkInsecureRandom},
{"go-discarded-auth-error", "HIGH", checkDiscardedAuthError},
{"go-tls-insecure-skip-verify", "HIGH", checkTLSInsecureSkipVerify},
{"go-permissive-file-mode", "MEDIUM", checkPermissiveFileMode},
{"go-open-redirect", "MEDIUM", checkOpenRedirect},
{"go-jwt-none-algorithm", "HIGH", checkJWTNoneAlgorithm},
{"go-cors-wildcard", "MEDIUM", checkCORSWildcard},
{"go-insecure-cookie", "MEDIUM", checkInsecureCookie},
{"go-path-traversal", "HIGH", checkPathTraversal},
{"go-cookie-missing-flags", "LOW", checkCookieMissingFlags},
{"go-ssrf", "HIGH", checkSSRF},
{"go-ssti", "HIGH", checkSSTI},
{"go-predictable-prng-seed", "MEDIUM", checkPredictablePRNGSeed},
{"go-empty-block", "LOW", checkEmptyBlock},
}
// Scan walks root, parses every .go file, and runs the rule set against each.
func Scan(root string) ([]model.Issue, error) {
fset := token.NewFileSet()
var issues []model.Issue
err := walk.Walk(root, func(path string, d fs.DirEntry) error {
switch {
case strings.HasSuffix(path, ".go"):
f, err := parser.ParseFile(fset, path, nil, parser.ParseComments)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
goCurrentParamSeed = goComputeParamSeed(f)
for _, r := range rules {
issues = append(issues, r.check(f, fset, path)...)
}
case strings.HasSuffix(path, ".py"):
pyIssues, err := scanPythonFile(path)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
issues = append(issues, pyIssues...)
case strings.HasSuffix(path, ".php"):
phpIssues, err := scanPHPFile(path)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
issues = append(issues, phpIssues...)
case strings.HasSuffix(path, ".rb"):
rubyIssues, err := scanRubyFile(path)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
issues = append(issues, rubyIssues...)
case strings.HasSuffix(path, ".java"):
javaIssues, err := scanJavaFile(path)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
issues = append(issues, javaIssues...)
default:
if lang := jsLangForPath(path); lang != nil {
jsIssues, err := scanJSFile(path, lang)
if err != nil {
return nil // ponytail: skip files that don't parse, don't fail the whole scan
}
issues = append(issues, jsIssues...)
}
}
return nil
})
return issues, err
}
func scanPythonFile(path string) ([]model.Issue, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tree, err := gts.NewParser(pyLang).Parse(src)
if err != nil {
return nil, err
}
tsCurrentParamSeed = tsComputeParamSeed(tree.RootNode(), pyLang, src, pyFuncBoundary, funcDefQuery, pyFreeCallQuery, identifierParamName, pyAssignInfo, pyExprTainted)
var issues []model.Issue
for _, r := range pyRules {
issues = append(issues, r.check(tree.RootNode(), src, path)...)
}
return issues, nil
}
func scanPHPFile(path string) ([]model.Issue, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tree, err := gts.NewParser(phpLang).Parse(src)
if err != nil {
return nil, err
}
tsCurrentParamSeed = tsComputeParamSeed(tree.RootNode(), phpLang, src, phpFuncBoundary, phpFuncDefQuery, phpFreeCallQuery, phpParamName, phpAssignInfo, phpExprTainted)
var issues []model.Issue
for _, r := range phpRules {
issues = append(issues, r.check(tree.RootNode(), src, path)...)
}
return issues, nil
}
func scanRubyFile(path string) ([]model.Issue, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tree, err := gts.NewParser(rubyLang).Parse(src)
if err != nil {
return nil, err
}
tsCurrentParamSeed = tsComputeParamSeed(tree.RootNode(), rubyLang, src, rubyFuncBoundary, rubyMethodDefQuery, rubyFreeCallQuery, identifierParamName, rubyAssignInfo, rubyExprTainted)
var issues []model.Issue
for _, r := range rubyRules {
issues = append(issues, r.check(tree.RootNode(), src, path)...)
}
return issues, nil
}
func scanJavaFile(path string) ([]model.Issue, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tree, err := gts.NewParser(javaLang).Parse(src)
if err != nil {
return nil, err
}
tsCurrentParamSeed = tsComputeParamSeed(tree.RootNode(), javaLang, src, javaFuncBoundary, javaMethodDefQuery, javaFreeCallQuery, identifierParamName, javaAssignInfo, javaExprTainted)
var issues []model.Issue
for _, r := range javaRules {
issues = append(issues, r.check(tree.RootNode(), src, path)...)
}
return issues, nil
}
// jsLangForPath maps a file extension to the grammar that parses it, or nil
// if the extension isn't JS/TS. .tsx gets its own grammar (JSX support
// layered onto TypeScript); plain .ts can't contain JSX.
func jsLangForPath(path string) *gts.Language {
switch {
case strings.HasSuffix(path, ".tsx"):
return tsxLang
case strings.HasSuffix(path, ".ts"), strings.HasSuffix(path, ".mts"), strings.HasSuffix(path, ".cts"):
return tsLang
case strings.HasSuffix(path, ".js"), strings.HasSuffix(path, ".jsx"), strings.HasSuffix(path, ".mjs"), strings.HasSuffix(path, ".cjs"):
return jsLang
default:
return nil
}
}
func scanJSFile(path string, lang *gts.Language) ([]model.Issue, error) {
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
tree, err := gts.NewParser(lang).Parse(src)
if err != nil {
return nil, err
}
tsCurrentParamSeed = tsComputeParamSeed(tree.RootNode(), lang, src, jsFuncBoundary, jsFuncDeclQuery.forLang(lang), jsFreeCallQuery.forLang(lang), identifierParamName, jsAssignInfo, jsExprTainted)
var issues []model.Issue
for _, r := range jsRules {
issues = append(issues, r.check(tree.RootNode(), lang, src, path)...)
}
return issues, nil
}
func issueAt(id, severity, path, title, message string, fset *token.FileSet, pos token.Pos) model.Issue {
p := fset.Position(pos)
return model.Issue{
Scanner: "sast",
RuleID: id,
Title: title,
Severity: severity,
File: path,
Line: p.Line,
Message: message,
CWEs: cweFor(id),
}
}
package sast
import "go/ast"
// Intraprocedural taint tracking for Go: within a single function body,
// track which local variables derive (directly or through concatenation/
// Sprintf/further assignment) from a known-tainted source, so sink rules can
// see through `next := r.URL.Query().Get("next")` rather than only matching
// r/req/request literally at the call site.
//
// ponytail ceiling: linear pass over the function body in AST order, not a
// real CFG — a taint assigned inside an `if` still taints for the rest of
// the function on every later read, even on paths where that branch didn't
// execute. Two passes handle a var tainted via another var assigned later
// in a shadowing/reordered chain; anything needing more than that (loops
// re-tainting across iterations, taint through function calls/returns,
// field-sensitivity beyond the fixed source list) isn't modeled. Good
// enough to kill the "request data hidden behind one local variable" false
// negative documented as the #1 ceiling in docs/guide/scanner/sast.md;
// not a dataflow engine.
// forEachGoFuncBody calls visit once per function body in f — each
// top-level FuncDecl and each FuncLit (closures get their own taint scope,
// not their enclosing function's).
func forEachGoFuncBody(f *ast.File, visit func(*ast.BlockStmt)) {
ast.Inspect(f, func(n ast.Node) bool {
switch v := n.(type) {
case *ast.FuncDecl:
if v.Body != nil {
visit(v.Body)
}
case *ast.FuncLit:
if v.Body != nil {
visit(v.Body)
}
}
return true
})
}
// inspectWithinFunc is ast.Inspect over body that stops at nested function
// literals, so a rule scanning one function's sinks doesn't also re-report
// sinks inside a closure that gets its own separate forEachGoFuncBody call.
func inspectWithinFunc(body *ast.BlockStmt, fn func(ast.Node) bool) {
ast.Inspect(body, func(n ast.Node) bool {
if _, ok := n.(*ast.FuncLit); ok {
return false
}
return fn(n)
})
}
// goCurrentParamSeed holds this file's precomputed same-file interprocedural
// parameter taint seeds (see goComputeParamSeed), keyed by function body —
// set once per file by Scan before that file's rules run, consulted
// transparently by goTaintEnv so none of rules.go's six call sites need to
// change.
//
// ponytail: file-scoped global, relies on Scan processing one file at a
// time sequentially — would need a per-goroutine/per-call context instead
// if file scanning is ever parallelized.
var goCurrentParamSeed map[*ast.BlockStmt]map[string]bool
// goTaintEnv returns the set of local variable names assigned (directly or
// transitively) from tainted input somewhere in body.
func goTaintEnv(body *ast.BlockStmt) map[string]bool {
return goTaintEnvWithSeed(body, goCurrentParamSeed[body])
}
// goTaintEnvWithSeed is goTaintEnv's actual implementation, taking an
// explicit initial taint set instead of consulting the package-level
// goCurrentParamSeed — used by goComputeParamSeed itself, which needs to
// build each round's per-function env from its own in-progress seed map.
func goTaintEnvWithSeed(body *ast.BlockStmt, seed map[string]bool) map[string]bool {
env := map[string]bool{}
for name := range seed {
env[name] = true
}
for range 2 { // second pass catches vars tainted via a var assigned later in source order
inspectWithinFunc(body, func(n ast.Node) bool {
assign, ok := n.(*ast.AssignStmt)
if !ok {
return true
}
for i, rhs := range assign.Rhs {
if i >= len(assign.Lhs) {
continue
}
lhs, ok := assign.Lhs[i].(*ast.Ident)
if !ok {
continue
}
if goExprTainted(rhs, env) {
env[lhs.Name] = true
}
}
return true
})
}
return env
}
// goInterprocFuncInfo captures one same-file, top-level free function's
// positional parameter names and body — used to build a same-file call
// graph. Free functions only, no methods: a Go method call needs the
// receiver's concrete type resolved to know which method it targets, the
// same "flag the candidate, not type-verified" line every rule in this
// codebase already draws, just applied to call resolution instead of a
// single call site.
type goInterprocFuncInfo struct {
params []string
body *ast.BlockStmt
}
func goBuildFuncRegistry(f *ast.File) map[string]goInterprocFuncInfo {
reg := map[string]goInterprocFuncInfo{}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil || fn.Body == nil {
continue
}
reg[fn.Name.Name] = goInterprocFuncInfo{params: goParamNames(fn.Type.Params), body: fn.Body}
}
return reg
}
func goParamNames(params *ast.FieldList) []string {
if params == nil {
return nil
}
var names []string
for _, field := range params.List {
if len(field.Names) == 0 {
names = append(names, "") // unnamed parameter (interface-style signature): never matches a real taint check
continue
}
for _, n := range field.Names {
names = append(names, n.Name)
}
}
return names
}
// goComputeParamSeed closes the "sink inside a helper function" gap
// documented at the top of this file: a same-file call graph among free
// functions, iterated a fixed 3 rounds (mirroring goTaintEnv's own
// 2-round bounded-iteration precedent — taint state only ever grows across
// rounds, so a recursive/cyclic call chain just stops improving within the
// round budget instead of looping forever). For each call site passing a
// tainted argument to a same-file free function, the corresponding
// parameter name is seeded as an additional taint source for that
// function's own body — so a sink rule using that parameter directly
// fires at its real location inside the callee, without any change to the
// sink rules themselves.
//
// Deliberately NOT built: return-value taint propagation. goExprTainted's
// CallExpr case already treats *any* call containing a tainted argument as
// tainted overall, regardless of what the callee does with it (pinned by
// TestGoTaintDoesNotCrossFunctionCalls) — a real limitation in the other
// direction (it can't recognize genuine sanitization, so it over-taints),
// but it means return-taint propagation is already covered, more broadly
// than a same-file registry could manage alone (it already works for
// calls this file can't see the body of at all).
func goComputeParamSeed(f *ast.File) map[*ast.BlockStmt]map[string]bool {
reg := goBuildFuncRegistry(f)
seed := map[*ast.BlockStmt]map[string]bool{}
for round := 0; round < 3; round++ {
for _, info := range reg {
env := goTaintEnvWithSeed(info.body, seed[info.body])
inspectWithinFunc(info.body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
id, ok := call.Fun.(*ast.Ident)
if !ok {
return true
}
callee, ok := reg[id.Name]
if !ok {
return true
}
for i, arg := range call.Args {
if i >= len(callee.params) || callee.params[i] == "" || !goExprTainted(arg, env) {
continue
}
if seed[callee.body] == nil {
seed[callee.body] = map[string]bool{}
}
seed[callee.body][callee.params[i]] = true
}
return true
})
}
}
return seed
}
// goExprTainted reports whether e evaluates from tainted input: rooted at
// r/req/request (goRootedAtRequest), an os.Getenv/os.LookupEnv call, a
// variable already known-tainted, or built from any of those via
// concatenation/fmt.Sprintf/Sprint.
func goExprTainted(e ast.Expr, env map[string]bool) bool {
if goRootedAtRequest(e) || goIsEnvSource(e) {
return true
}
switch v := e.(type) {
case *ast.Ident:
return env[v.Name]
case *ast.BinaryExpr:
return goExprTainted(v.X, env) || goExprTainted(v.Y, env)
case *ast.CallExpr:
for _, arg := range v.Args {
if goExprTainted(arg, env) {
return true
}
}
return false
case *ast.ParenExpr:
return goExprTainted(v.X, env)
default:
return false
}
}
func goIsEnvSource(e ast.Expr) bool {
call, ok := e.(*ast.CallExpr)
if !ok {
return false
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && id.Name == "os" && (sel.Sel.Name == "Getenv" || sel.Sel.Name == "LookupEnv")
}
package sast
import gts "github.com/odvcencio/gotreesitter"
// Shared scaffolding for intraprocedural taint tracking across the
// tree-sitter-backed languages (Python, JS/TS/TSX, PHP, Ruby, Java) — the
// same idea as taint.go's Go-specific version, generalized over a node
// tree instead of go/ast. Verified directly (not assumed, per this
// project's usual rule) that every function/method/lambda/closure node
// across all five grammars exposes its body via a field literally named
// "body", including single-expression arrow/lambda bodies that aren't a
// block at all — so tsEnclosingBody below needs no per-node-type special
// casing.
//
// ponytail ceiling: same as Go's — a linear pass over the function body in
// tree order, not a real CFG (branch-insensitive), and taint doesn't cross
// a function call (a value passed to a helper and returned tainted isn't
// tracked). Each language supplies its own node-shape predicates
// (boundary type set, assignInfo, exprTainted) below in its own file.
// tsWalkWithinScope calls visit on every descendant of n, skipping
// subtrees rooted at a node whose type is in boundary — those get their
// own separate taint scope from their own top-level tsEnclosingBody/
// tsTaintEnv call, so a closure's locals don't leak into the enclosing
// function's env or vice versa.
func tsWalkWithinScope(n *gts.Node, lang *gts.Language, boundary map[string]bool, visit func(*gts.Node)) {
for _, c := range n.Children() {
if boundary[c.Type(lang)] {
continue
}
visit(c)
tsWalkWithinScope(c, lang, boundary, visit)
}
}
// tsEnclosingBody walks n's ancestors for the nearest node whose type is
// in boundary, and returns its "body" field — nil if n isn't inside one of
// those, or that one has no body (e.g. an abstract/interface method).
func tsEnclosingBody(n *gts.Node, lang *gts.Language, boundary map[string]bool) *gts.Node {
for p := n.Parent(); p != nil; p = p.Parent() {
if boundary[p.Type(lang)] {
return p.ChildByFieldName("body", lang)
}
}
return nil
}
// tsTaintEnv builds the set of local variable names assigned (directly, or
// transitively through a chain of assignments) from tainted input
// somewhere in body. assignInfo pulls (name, rhsExpr, ok) out of an
// assignment-shaped node; exprTainted decides whether a given expression
// evaluates from tainted input given the taint state built so far.
//
// Transparently seeded from tsCurrentParamSeed (see interproc.go) so every
// existing call site — and every sink rule that goes through it — sees a
// same-file interprocedurally-tainted parameter without any change of its
// own: the seed is folded into env before the usual intraprocedural pass
// runs, so a sink using that parameter directly behaves exactly as if the
// parameter had been r/req-rooted to begin with.
func tsTaintEnv(
body *gts.Node,
lang *gts.Language,
src []byte,
boundary map[string]bool,
assignInfo func(n *gts.Node, lang *gts.Language, src []byte) (name string, rhs *gts.Node, ok bool),
exprTainted func(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool,
) map[string]bool {
return tsTaintEnvWithSeed(body, lang, src, boundary, assignInfo, exprTainted, tsCurrentParamSeed[body])
}
// tsTaintEnvWithSeed is tsTaintEnv's actual implementation, taking an
// explicit initial taint set instead of consulting the package-level
// tsCurrentParamSeed — used by tsComputeParamSeed itself (interproc.go),
// which needs to build each round's per-function env from its own
// in-progress seed map, not the previous file's leftover global state.
func tsTaintEnvWithSeed(
body *gts.Node,
lang *gts.Language,
src []byte,
boundary map[string]bool,
assignInfo func(n *gts.Node, lang *gts.Language, src []byte) (name string, rhs *gts.Node, ok bool),
exprTainted func(n *gts.Node, lang *gts.Language, src []byte, env map[string]bool) bool,
seed map[string]bool,
) map[string]bool {
env := map[string]bool{}
for name := range seed {
env[name] = true
}
if body == nil {
return env
}
for range 2 { // second pass: a var tainted via another var assigned later in source order
tsWalkWithinScope(body, lang, boundary, func(n *gts.Node) {
name, rhs, ok := assignInfo(n, lang, src)
if !ok {
return
}
if exprTainted(rhs, lang, src, env) {
env[name] = true
}
})
}
return env
}
package secret
import "math"
func shannonEntropy(s string) float64 {
if s == "" {
return 0
}
counts := make(map[rune]int)
for _, r := range s {
counts[r]++
}
var entropy float64
n := float64(len(s))
for _, c := range counts {
p := float64(c) / n
entropy -= p * math.Log2(p)
}
return entropy
}
package secret
import (
"bufio"
"context"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"github.com/colibrisec/ojo/internal/model"
)
// ponytail: shells out to the system git binary (git log -p) rather than
// adding a go-git dependency -- git is already a hard requirement to have
// a repo to scan in the first place, and -p's unified diff is exactly the
// "what lines were added, in what file, at what line" data this needs.
var hunkHeaderRe = regexp.MustCompile(`^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
// ScanGitHistory greps every line ever added (git log -p on the currently
// checked-out branch's history, -U0 so each hunk is only changed lines) for
// the same rules Scan checks the working tree with. This is what catches a
// secret that was committed and later removed -- Scan can't see it, since
// it never exists on disk at scan time.
//
// ponytail ceiling: the current branch's reachable history only, not every
// branch/tag (no --all) -- predictable ("scan what's checked out plus its
// ancestry"), and bounded. Add --all if secrets hiding in unmerged
// branches turns out to matter. One Issue per commit a secret was added
// in, not deduplicated -- each is a real historical exposure (same
// precedent as gitleaks/trufflehog).
func ScanGitHistory(ctx context.Context, root string, extraRules []Rule) ([]model.Issue, error) {
rules, err := DefaultRules()
if err != nil {
return nil, err
}
rules, err = mergeRules(rules, extraRules)
if err != nil {
return nil, err
}
if err := exec.CommandContext(ctx, "git", "-C", root, "rev-parse", "--is-inside-work-tree").Run(); err != nil {
return nil, fmt.Errorf("%s is not a git repository: %w", root, err)
}
cmd := exec.CommandContext(ctx, "git", "-C", root, "log", "-p", "--no-color", "--unified=0")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
var issues []model.Issue
var commit, path string
var isTestFile bool
lineNum := 0
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "commit "):
commit = strings.TrimSpace(strings.TrimPrefix(line, "commit "))
if len(commit) > 7 {
commit = commit[:7]
}
case strings.HasPrefix(line, "+++ "):
if line == "+++ /dev/null" {
path = "" // file deleted in this commit, nothing to scan
} else {
path = strings.TrimPrefix(line, "+++ b/")
isTestFile = isLikelyTestFile(path)
}
case hunkHeaderRe.MatchString(line):
m := hunkHeaderRe.FindStringSubmatch(line)
lineNum, _ = strconv.Atoi(m[1])
case strings.HasPrefix(line, "+"):
if path != "" && isConfigFile(path) {
content := line[1:]
lower := strings.ToLower(content)
for _, r := range rules {
ok, m := ruleApplies(r, content, lower)
if !ok {
continue
}
if isTestFile && looksLikePlaceholder(m) {
continue
}
issues = append(issues, model.Issue{
Scanner: "secret",
RuleID: r.ID,
Title: r.Description,
Severity: r.Severity,
File: path,
Line: lineNum,
Match: redact(content),
Message: fmt.Sprintf("%s detected in git history (commit %s)", r.Description, commit),
CWEs: ruleCWEs[r.ID],
})
}
}
lineNum++
}
}
scanErr := sc.Err()
waitErr := cmd.Wait()
if scanErr != nil {
return nil, scanErr
}
if waitErr != nil {
return nil, fmt.Errorf("git log: %w", waitErr)
}
return issues, nil
}
package secret
import (
"path/filepath"
"strings"
)
var testPathSegments = map[string]bool{
"test": true,
"tests": true,
"testdata": true,
"fixtures": true,
"__tests__": true,
"mocks": true,
}
func isLikelyTestFile(path string) bool {
slash := filepath.ToSlash(path)
base := strings.ToLower(filepath.Base(slash))
if strings.HasSuffix(base, "_test.go") ||
strings.Contains(base, ".test.") ||
strings.Contains(base, ".spec.") {
return true
}
for _, seg := range strings.Split(slash, "/") {
if testPathSegments[strings.ToLower(seg)] {
return true
}
}
return false
}
var awsDocumentationCredentials = map[string]bool{
"AKIAIOSFODNN7EXAMPLE": true,
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY": true,
}
var placeholderMarkers = []string{
"example", "placeholder", "changeme", "dummy", "fake", "sample",
"foobar", "hunter2", "yourkey", "testtest", "notreal", "redacted",
}
func looksLikePlaceholder(secret string) bool {
lower := strings.ToLower(secret)
for _, m := range placeholderMarkers {
if strings.Contains(lower, m) {
return true
}
}
return hasSequentialOrRepeatedRun(secret, 8)
}
func hasSequentialOrRepeatedRun(s string, minRun int) bool {
if len(s) < minRun {
return false
}
repeatRun, seqRun := 1, 1
for i := 1; i < len(s); i++ {
prev, cur := s[i-1], s[i]
if cur == prev {
repeatRun++
} else {
repeatRun = 1
}
if cur == prev+1 {
seqRun++
} else {
seqRun = 1
}
if repeatRun >= minRun || seqRun >= minRun {
return true
}
}
return false
}
package secret
import (
"bytes"
_ "embed"
"fmt"
"os"
"regexp"
"gopkg.in/yaml.v3"
)
//go:embed default_rules.yaml
var defaultRulesYAML []byte
type Rule struct {
ID string `yaml:"id"`
Description string `yaml:"description"`
Regex string `yaml:"regex"`
Keywords []string `yaml:"keywords"`
MinEntropy float64 `yaml:"minEntropy"`
Severity string `yaml:"severity"`
compiled *regexp.Regexp
}
type ruleFile struct {
Rules []Rule `yaml:"rules"`
}
func DefaultRules() ([]Rule, error) {
var rf ruleFile
if err := yaml.Unmarshal(defaultRulesYAML, &rf); err != nil {
return nil, fmt.Errorf("parsing default secret rules: %w", err)
}
if err := compileRules(rf.Rules); err != nil {
return nil, err
}
return rf.Rules, nil
}
// LoadRules reads additional secret rules from a user-supplied YAML file —
// the same "rules: [...]" shape as the embedded defaults — so a user rule
// is a copy-pasteable variant of a default one. An empty path means no
// custom rules, same "absent means off" policy as --rules-dir/.ojo.yaml.
func LoadRules(path string) ([]Rule, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var rf ruleFile
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&rf); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if err := compileRules(rf.Rules); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return rf.Rules, nil
}
func compileRules(rules []Rule) error {
for i := range rules {
if rules[i].ID == "" {
return fmt.Errorf("rule missing id")
}
re, err := regexp.Compile(rules[i].Regex)
if err != nil {
return fmt.Errorf("rule %s: %w", rules[i].ID, err)
}
rules[i].compiled = re
}
return nil
}
// mergeRules appends extra to base, erroring on an id collision so a custom
// rule can't silently shadow (or duplicate) a default one.
func mergeRules(base, extra []Rule) ([]Rule, error) {
seen := map[string]bool{}
for _, r := range base {
seen[r.ID] = true
}
for _, r := range extra {
if seen[r.ID] {
return nil, fmt.Errorf("rule id %q already defined", r.ID)
}
seen[r.ID] = true
}
return append(base, extra...), nil
}
package secret
import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/walk"
)
const maxFileSize = 5 << 20
// configExts are file extensions commonly used for configuration, where
// hardcoded secrets are the expected failure mode. Scanning is limited to
// these (plus configNames below) instead of every source file, to keep
// noise/runtime down on large repos.
// ponytail: extension allowlist, add entries if a common config format is missing rather than reworking the approach.
var configExts = map[string]bool{
".env": true, ".yaml": true, ".yml": true, ".json": true, ".toml": true,
".ini": true, ".cfg": true, ".conf": true, ".properties": true, ".xml": true,
".pem": true, ".key": true, ".md": true,
}
var configNames = map[string]bool{
"dockerfile": true, ".npmrc": true, ".pypirc": true, ".netrc": true,
".htpasswd": true, ".git-credentials": true, ".dockercfg": true,
}
func isConfigFile(path string) bool {
base := strings.ToLower(filepath.Base(path))
if configNames[base] || strings.HasPrefix(base, ".env") {
return true
}
return configExts[filepath.Ext(base)]
}
// Scan runs the built-in secret rules, plus extraRules (from
// --secret-rules-file, if any), against every config-shaped file under
// root.
func Scan(root string, extraRules []Rule) ([]model.Issue, error) {
rules, err := DefaultRules()
if err != nil {
return nil, err
}
rules, err = mergeRules(rules, extraRules)
if err != nil {
return nil, err
}
var issues []model.Issue
err = walk.Walk(root, func(path string, d fs.DirEntry) error {
if !isConfigFile(path) {
return nil
}
info, err := os.Stat(path)
if err != nil || info.Size() > maxFileSize {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
if isBinary(f) {
return nil
}
lineNum := 0
isTestFile := isLikelyTestFile(path)
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
lineNum++
line := scanner.Text()
lower := strings.ToLower(line)
for _, r := range rules {
ok, m := ruleApplies(r, line, lower)
if !ok {
continue
}
if isTestFile && looksLikePlaceholder(m) {
continue
}
issues = append(issues, model.Issue{
Scanner: "secret",
RuleID: r.ID,
Title: r.Description,
Severity: r.Severity,
File: path,
Line: lineNum,
Match: redact(line),
Message: fmt.Sprintf("%s detected", r.Description),
CWEs: ruleCWEs[r.ID],
})
}
}
return nil
})
return issues, err
}
func ruleApplies(r Rule, line, lowerLine string) (bool, string) {
if len(r.Keywords) > 0 {
matched := false
for _, kw := range r.Keywords {
if strings.Contains(lowerLine, kw) {
matched = true
break
}
}
if !matched {
return false, ""
}
}
// A line can hold more than one candidate match -- e.g. a JSON
// "KEY_NAME": "value" pair, where the quoted key itself matches the
// generic pattern before the real secret does. Check every match, not
// just the first, so an early low-entropy match (a label) can't shadow
// a real one later on the same line.
for _, m := range r.compiled.FindAllString(line, -1) {
if awsDocumentationCredentials[m] {
continue
}
if r.MinEntropy == 0 || shannonEntropy(m) >= r.MinEntropy {
return true, m
}
}
return false, ""
}
func redact(line string) string {
line = strings.TrimSpace(line)
if len(line) > 80 {
line = line[:80] + "..."
}
return line
}
func isBinary(f *os.File) bool {
defer f.Seek(0, 0)
buf := make([]byte, 512)
n, _ := f.Read(buf)
return bytes.IndexByte(buf[:n], 0) != -1
}
// Package vex implements OpenVEX (https://openvex.dev) document generation
// and consumption.
//
// Generation is deliberately low-ambition: ojo has no reachability
// analysis, so the only status it can honestly assert for a finding is
// "affected" -- it found the vulnerable package in the resolved dependency
// tree, full stop. It cannot know whether the vulnerable code path is
// actually reachable, which is what a "not_affected" status requires
// justifying. The real value of this package is the other direction --
// consuming a VEX document a human or another tool authored, and
// suppressing findings its not_affected/fixed statements cover, the same
// way .ojoignore does, just against a standard interchange format instead
// of an ojo-specific one.
package vex
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
"github.com/colibrisec/ojo/internal/ignore"
"github.com/colibrisec/ojo/internal/model"
"github.com/colibrisec/ojo/internal/report"
)
const contextURL = "https://openvex.dev/ns/v0.2.0"
type Document struct {
Context string `json:"@context"`
Author string `json:"author"`
Timestamp string `json:"timestamp"`
Version int `json:"version"`
Statements []Statement `json:"statements"`
}
type Statement struct {
Vulnerability vulnerability `json:"vulnerability"`
Products []product `json:"products"`
Status string `json:"status"`
Justification string `json:"justification,omitempty"`
ImpactStatement string `json:"impact_statement,omitempty"`
}
type vulnerability struct {
Name string `json:"name"`
}
type product struct {
ID string `json:"@id,omitempty"`
Identifiers struct {
PURL string `json:"purl,omitempty"`
} `json:"identifiers,omitempty"`
}
// Generate builds an OpenVEX document asserting "affected" for every
// vulnerability in findings -- see the package doc for why that's the only
// status ojo can honestly emit on its own.
func Generate(findings []model.Finding, author string, now time.Time) Document {
doc := Document{Context: contextURL, Author: author, Timestamp: now.UTC().Format(time.RFC3339), Version: 1}
for _, f := range findings {
p := product{ID: report.Purl(f.Package)}
for _, v := range f.Vulns {
doc.Statements = append(doc.Statements, Statement{
Vulnerability: vulnerability{Name: v.ID},
Products: []product{p},
Status: "affected",
})
}
}
return doc
}
func Write(w io.Writer, doc Document) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(doc)
}
// Load reads an OpenVEX document's statements from path. "" means no VEX
// file (nil, nil). Unlike .ojoignore or ojo's custom-rule YAML -- ojo's own
// formats, parsed strictly to catch typos -- this doesn't reject unknown
// fields: it's an external interchange format other tools and vendors
// author, and a real-world document routinely carries fields ojo doesn't
// model (e.g. "role", "supplier", per-statement "@id").
func Load(path string) ([]Statement, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var doc Document
if err := json.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return doc.Statements, nil
}
// Apply suppresses each finding vulnerability covered by a statement whose
// status is "not_affected" or "fixed" (matched by product purl and
// vulnerability ID/alias). Returns the same shape ignore.Apply does, so the
// caller can merge VEX suppressions into a Report.SuppressedFindings list
// built from .ojoignore right alongside them.
//
// ponytail ceiling: product matching is exact-string purl equality, no
// normalization (case, missing version qualifiers, alternate purl
// spellings for the same package all fail to match) -- a statement's
// product must use the same purl shape internal/report.Purl produces for
// that ecosystem.
func Apply(findings []model.Finding, statements []Statement) (kept []model.Finding, suppressed []ignore.SuppressedFinding) {
for _, f := range findings {
purl := report.Purl(f.Package)
var keptVulns []model.Vulnerability
for _, v := range f.Vulns {
if reason, ok := matchStatement(statements, v, purl); ok {
suppressed = append(suppressed, ignore.SuppressedFinding{Package: f.Package, Vuln: v, Reason: reason})
} else {
keptVulns = append(keptVulns, v)
}
}
if len(keptVulns) > 0 {
f.Vulns = keptVulns
kept = append(kept, f)
}
}
return kept, suppressed
}
func matchStatement(statements []Statement, v model.Vulnerability, purl string) (string, bool) {
for _, s := range statements {
if s.Status != "not_affected" && s.Status != "fixed" {
continue
}
if !vulnMatches(s.Vulnerability.Name, v) || !productMatches(s.Products, purl) {
continue
}
reason := "VEX: " + s.Status
if s.Justification != "" {
reason += " (" + s.Justification + ")"
}
return reason, true
}
return "", false
}
func vulnMatches(name string, v model.Vulnerability) bool {
if name == v.ID {
return true
}
for _, a := range v.Aliases {
if name == a {
return true
}
}
return false
}
func productMatches(products []product, purl string) bool {
for _, p := range products {
if p.ID == purl || p.Identifiers.PURL == purl {
return true
}
}
return false
}
package walk
import (
"bytes"
"io/fs"
"os/exec"
"path/filepath"
"strings"
)
var defaultSkipDirs = map[string]bool{
"node_modules": true,
".git": true,
"vendor": true,
}
var gitIgnored map[string]bool
// RespectGitignore makes Walk skip untracked files that git ignores under
// root. Outside a git repository, or without git, it has no effect. An empty
// root clears the setting.
func RespectGitignore(root string) {
gitIgnored = nil
if root == "" {
return
}
abs, err := filepath.Abs(root)
if err != nil {
return
}
out, err := exec.Command("git", "-C", abs, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z").Output()
if err != nil {
return
}
ignored := map[string]bool{}
for _, p := range bytes.Split(out, []byte{0}) {
if len(p) == 0 {
continue
}
ignored[filepath.Join(abs, filepath.FromSlash(strings.TrimSuffix(string(p), "/")))] = true
}
gitIgnored = ignored
}
func isGitIgnored(path string) bool {
if gitIgnored == nil {
return false
}
abs, err := filepath.Abs(path)
if err != nil {
return false
}
return gitIgnored[abs]
}
// Walk visits every regular file under root, skipping common vendor/VCS directories.
func Walk(root string, fn func(path string, d fs.DirEntry) error) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if isGitIgnored(path) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
if defaultSkipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
return fn(path, d)
})
}
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"github.com/colibrisec/ojo/internal/cli"
)
// run turns cli.Root()'s result into a process exit code, printing err to
// stderr unless it's the "exit 1, print nothing extra" sentinel. Separated
// from main so it's testable without actually running a command.
func run(err error, stderr io.Writer) int {
if err == nil {
return 0
}
if !errors.Is(err, cli.ErrFindingsFound) {
fmt.Fprintln(stderr, err)
}
return 1
}
func main() {
os.Exit(run(cli.Root().ExecuteContext(context.Background()), os.Stderr))
}