Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Errors go to stderr and return a non-zero exit code. With `--json`, errors also
## Commands

```text
brainpod detect [<path>] [--rules <rules.toml>] [--with-env]
brainpod detect [<path>] [--rules <rules.toml>] [--with-env [--with-source]]
brainpod describe [<command>...]
brainpod describe resource [<kind>]
brainpod login
Expand Down Expand Up @@ -272,12 +272,17 @@ Inspect a directory without credentials, network access, subprocesses, or execut
brainpod detect .
brainpod detect ./services --json
brainpod detect . --with-env --json
brainpod detect . --with-env --with-source --json
brainpod detect . --rules ./detection.toml --json
```

The result contains sorted project paths, manifest names, languages and version declarations, priority-ranked detections with evidence and `uses`/`provides` framework roles, workspace members, diagnostics, and scan statistics. Optional `runtimes` records Deno declarations separately from JavaScript/TypeScript versions; `workspaces` records each manager’s ecosystem, source, and members, while `workspaceMembers` remains their union. Framework versions are attached to dependency evidence. JavaScript versions describe Node.js; TypeScript versions describe the compiler. C# versions distinguish `language` (LangVersion), `target` (TFM), `runtime`, and `sdk`; Java distinguishes compiler `source`/`target`/`release`, toolchains, build-JDK metadata, and pins. Requirements and pins retain their source and kind: these are declarations, not a claim about installed versions. Missing versions remain unknown.

Add `--with-env` for name-only environment declarations from dotenv files (including examples/templates), Compose, Dockerfile `ENV`, conventional Kubernetes manifests, and GitHub Actions workflow/job/step `env` maps. JSON adds `environment`; text shows names, source/format/location, project ownership and unresolved references. Values are never exposed, loaded or evaluated. References are not followed, and declarations are not a claim about usage or installed/effective values. Without the flag, output and read counts are unchanged. See [supported syntax, privacy/scoping limits and offline validation](tests/detect-env.md).
Add `--with-env` for name-only environment declarations from dotenv files (including examples/templates), Compose, Dockerfile `ENV` and `ARG` (build-scoped), conventional Kubernetes manifests, GitHub Actions workflow/job/step `env` maps, and deployment manifests of other hosting platforms: Heroku/Dokku `app.json`, Scalingo `scalingo.json`, `heroku.yml`, Render `render.yaml`, Fly.io `fly.toml`, DigitalOcean `.do/app.yaml`/`.do/deploy.template.yaml`, Vercel `vercel.json`/`vercel.toml`, Netlify `netlify.toml`, Google App Engine `app.yaml` and AWS Copilot `copilot/*/manifest.yml`. Platform records add optional `required`, `secret`, `generated`, `hasDefault` (a literal value exists; never the value) and `scope` (`runtime`, `build`, `build-and-runtime`) fields only where the manifest states them, and database/service/secret wiring as a `reference`.

`--with-env` reads only manifest, config and deployment files, never application source. Add `--with-source` (requires `--with-env`) to also **read application source files** (JavaScript/TypeScript, Vue/Svelte/Astro, Python, Ruby, Go, Rust, PHP, Java/Kotlin, C#, Elixir; existing directory exclusions and the 1 MiB limit apply) and search them literally for a cataloged list of platform-injected names (for example `VERCEL_URL`, `RENDER_GIT_COMMIT`, `HEROKU_SLUG_COMMIT`, `CF_PAGES_URL`) and framework prefixes (`NEXT_PUBLIC_*`, `VITE_*`, `REACT_APP_*`, `NUXT_PUBLIC_*`, `EXPO_PUBLIC_*`, …). This is not a general read-site scanner: arbitrary `process.env` reads are not inferred. Each `(project, name)` becomes one `code-reference` record with `origin`, `provider`, `scope` and up to 20 file/line occurrences, never code text. On the pinned corpus `--with-source` reads about 40 times as many files as `--with-env` alone: 277,283 files / 1.77 GB versus 6,822 files / 15.1 MB.

JSON adds `environment`; text shows names, source/format/location, project ownership, unresolved references and the optional fields. Values are never exposed, loaded or evaluated. References are not followed, and declarations are not a claim about usage or installed/effective values. Without the flag, output and read counts are unchanged. See [supported syntax, per-platform mapping, code-reference patterns, privacy/scoping limits and offline validation](tests/detect-env.md).

Built-in coverage includes Laravel, Symfony, CakePHP, CodeIgniter, Composer; Go modules/workspaces and Gin/Echo/Fiber; Cargo workspaces, axum, Rocket, Actix; Bundler, Rails, Sinatra, Hanami; Python requirements, PEP 621, Poetry, Pipenv, uv workspaces, Django, FastAPI, Flask, Starlette, Celery; npm/pnpm and Deno workspaces and catalogs, Fresh, Oak, Hono, Next.js, Nuxt, Astro, SvelteKit, Remix, Angular, Express, React, Vue, Vite; C#/.NET projects and solutions, ASP.NET Core/classic, Aspire, FastEndpoints; Java/JVM Maven and Gradle projects, Spring/Spring Boot, Quarkus, Micronaut; and standalone source files in all nine languages. Rust workspace dependency aliases and inherited `rust-version` are understood. Ancestor runtime pin/toolchain files are included with provenance. Poetry names, literal gemspec metadata, and Rails component dependencies are recognized. pnpm catalog references retain the original declaration and add the locally resolved constraint with its YAML source; unresolved references produce diagnostics.

Expand Down
9 changes: 8 additions & 1 deletion src/detect/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub(super) enum Resolver {
pub(super) struct CatalogSpec {
manifests: Vec<Manifest>,
environment: Vec<super::environment::Spec>,
code_references: super::environment::references::Spec,
scan: Scan,
families: BTreeMap<String, String>,
inheritance: Vec<Inheritance>,
Expand Down Expand Up @@ -251,6 +252,7 @@ struct WorkspaceField {
pub(super) struct Catalog {
spec: CatalogSpec,
pub environment: super::environment::Catalog,
pub references: super::environment::references::Catalog,
exact: HashMap<String, Vec<usize>>,
wildcard: Vec<(FilePattern, usize)>,
extensions: HashMap<String, Vec<(FilePattern, usize)>>,
Expand All @@ -263,6 +265,7 @@ impl CatalogSpec {
pub fn extend(&mut self, extra: Self) {
self.manifests.extend(extra.manifests);
self.environment.extend(extra.environment);
self.code_references.extend(extra.code_references);
self.scan.ignore.extend(extra.scan.ignore);
self.scan.prune.extend(extra.scan.prune);
self.families.extend(extra.families);
Expand All @@ -274,10 +277,14 @@ impl CatalogSpec {
}

impl Catalog {
pub fn new(spec: CatalogSpec) -> Result<Self> {
pub fn new(mut spec: CatalogSpec) -> Result<Self> {
let environment = super::environment::Catalog::new(&spec.environment)?;
let references = super::environment::references::Catalog::new(std::mem::take(
&mut spec.code_references,
))?;
let mut catalog = Self {
environment,
references,
spec,
exact: HashMap::new(),
wildcard: Vec::new(),
Expand Down
139 changes: 130 additions & 9 deletions src/detect/environment.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
mod platforms;
pub(super) mod references;

use std::path::Path;

use anyhow::{Result, bail};
Expand All @@ -15,6 +18,17 @@ pub enum Format {
Dockerfile,
Kubernetes,
GithubActions,
AppJson,
HerokuYml,
Render,
Fly,
Digitalocean,
Vercel,
Netlify,
AppEngine,
Copilot,
/// Names found by the catalog's literal code-reference patterns; never routed to files.
CodeReference,
}

#[derive(Deserialize)]
Expand All @@ -41,7 +55,8 @@ impl Catalog {
pub fn new(specs: &[Spec]) -> Result<Self> {
let mut entries = Vec::new();
for spec in specs {
if spec.files.is_empty()
if spec.format == Format::CodeReference
|| spec.files.is_empty()
|| spec
.files
.iter()
Expand Down Expand Up @@ -80,6 +95,7 @@ impl Catalog {
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct Declaration {
pub source: String,
pub format: Format,
Expand All @@ -90,6 +106,59 @@ pub struct Declaration {
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference: Option<Reference>,
#[serde(flatten)]
pub semantics: Semantics,
#[serde(flatten)]
pub code: Option<CodeReference>,
}

/// Provenance of a `code-reference` record: which catalog pattern matched and where.
#[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct CodeReference {
pub origin: Origin,
pub provider: String,
/// Sorted `(source, line)` occurrences, capped at `references::MAX_OCCURRENCES`.
pub occurrences: Vec<Occurrence>,
pub occurrence_count: usize,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Occurrence {
pub source: String,
pub line: usize,
}

#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Origin {
Platform,
Framework,
}

/// Platform-declared facts about a declaration. Each field is present only when
/// the metadata format states it; literal values are never retained.
#[derive(Clone, Copy, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "camelCase")]
pub struct Semantics {
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub generated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_default: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<Scope>,
}

#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Scope {
Runtime,
Build,
BuildAndRuntime,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
Expand Down Expand Up @@ -117,6 +186,17 @@ impl Parser<'_> {
name: Option<&str>,
state: &str,
reference: Option<Reference>,
) {
self.declare(location, name, state, reference, Semantics::default());
}

fn declare(
&mut self,
location: &str,
name: Option<&str>,
state: &str,
reference: Option<Reference>,
semantics: Semantics,
) {
if name.is_some_and(|name| !identifier(name)) {
self.warn("unsupported or dynamic environment variable name");
Expand All @@ -130,6 +210,8 @@ impl Parser<'_> {
name: name.map(str::to_owned),
state: state.to_owned(),
reference,
semantics,
code: None,
});
}

Expand Down Expand Up @@ -208,6 +290,17 @@ pub(super) fn parse(
match format {
Format::Dotenv => dotenv(&mut parser, text),
Format::Dockerfile => dockerfile(&mut parser, text),
_ if source.ends_with(".toml") => match toml::from_str::<Value>(text) {
Ok(doc) => document(&mut parser, &doc, "document:0"),
Err(_) => parser.warn("environment TOML is invalid"),
},
// Platform JSON manifests follow JSON (last duplicate key wins) like their platforms.
Format::AppJson | Format::Vercel if source.ends_with(".json") => {
match serde_json::from_str::<Value>(text) {
Ok(doc) => document(&mut parser, &doc, "document:0"),
Err(_) => parser.warn("environment JSON is invalid"),
}
}
_ => {
let options = serde_saphyr::options! {
budget: serde_saphyr::budget! {
Expand All @@ -227,13 +320,7 @@ pub(super) fn parse(
match serde_saphyr::from_multiple_with_options::<Value>(text, options) {
Ok(documents) => {
for (index, doc) in documents.iter().enumerate() {
let location = format!("document:{index}");
match format {
Format::Compose => compose(&mut parser, doc, &location),
Format::Kubernetes => kubernetes(&mut parser, doc, &location),
Format::GithubActions => actions(&mut parser, doc, &location),
_ => (),
}
document(&mut parser, doc, &format!("document:{index}"));
}
}
Err(_) => parser.warn("environment YAML is invalid or exceeds parser limits"),
Expand All @@ -243,6 +330,15 @@ pub(super) fn parse(
parser.declarations
}

fn document(parser: &mut Parser<'_>, doc: &Value, location: &str) {
match parser.format {
Format::Compose => compose(parser, doc, location),
Format::Kubernetes => kubernetes(parser, doc, location),
Format::GithubActions => actions(parser, doc, location),
_ => platforms::parse(parser, doc, location),
}
}

fn identifier(name: &str) -> bool {
let mut chars = name.bytes();
chars
Expand Down Expand Up @@ -419,6 +515,32 @@ fn dockerfile(parser: &mut Parser<'_>, text: &str) {
parser.warn("Dockerfile heredoc and subsequent instructions not inspected");
return;
}
let location = format!("stage:{stage}/line:{}", index + 1);
if command.eq_ignore_ascii_case("ARG") {
match words(body, escape) {
Some(words) if !words.is_empty() => {
for (index, word) in words.iter().enumerate() {
let (name, has_default) = word
.split_once('=')
.map_or((word.as_str(), false), |(name, _)| (name, true));
let semantics = Semantics {
has_default: Some(has_default),
scope: Some(Scope::Build),
..Semantics::default()
};
parser.declare(
&format!("{location}/{index}"),
Some(name),
"declared",
None,
semantics,
);
}
}
_ => parser.warn("malformed Dockerfile ARG instruction"),
}
continue;
}
if !command.eq_ignore_ascii_case("ENV") {
continue;
}
Expand All @@ -430,7 +552,6 @@ fn dockerfile(parser: &mut Parser<'_>, text: &str) {
parser.warn("empty Dockerfile ENV instruction");
continue;
};
let location = format!("stage:{stage}/line:{}", index + 1);
if first.starts_with("--") {
parser.warn("Dockerfile ENV flags are not supported");
} else if first.contains('=') {
Expand Down
Loading
Loading