Files
Thomas Chauchefoin 82e8e0ad77 insecure-defaults: convert the skill to a dynamic workflow (#224)
* insecure-defaults: convert the skill to a dynamic workflow

Rewrites the plugin as a four-phase workflow: recon profiles the target,
parallel sweeps collect candidates, a refuting pass adjudicates them,
and a report assigns severity with coverage accounting.

The skill is removed and `/insecure-defaults:audit [path]` is the only
entry point, since the workflow needs the command to locate its
detection corpora.

It also adds offline tests: scenarios over the workflow's control flow, a
mutation self-test that proves they bite, and a check that every documented
example is matched by a seed pattern. This does not replace running the
command end-to-end against a real codebase.

Bumps to 2.0.0.

* insecure-defaults: run the node suites in CI

The harness and seed-coverage checks ran only when someone remembered to.
CI's shell-suite discovery matches plugins/*/tests/run_*.sh, so wrap the
three node invocations in run_seeds.sh and both the lint workflow and
`make shell-suites` pick them up with no changes to either.

Without this, adding a row to CATEGORIES without its references/<id>.json
passes CI and then aborts every real run with corpus-unreadable.

No setup-node step: ubuntu-latest ships Node, and the scripts are plain
CommonJS with no dependencies. The command -v guard makes a missing
interpreter a loud failure rather than a suite that quietly does not run.

* insecure-defaults: abort when the verify phase adjudicates nothing

If every verify batch died, confirmed was empty and the run returned
no-findings-confirmed, which commands/audit.md considers a completed
audit. Guard on unadjudicated.length === candidates.length and return
verify-failed, carrying coverage so the caller sees what went unjudged.

Adds a scenario for both failure shapes (all agents dead, all verdict lists
empty) and two mutations covering the guard firing and over-firing.

* insecure-defaults: report per-category scan counts

The zero-scanned guard is on the sum, so five failed searches beside one
that worked cleared it and categories_run listed all six. Add
files_scanned_by_category and unsearched_categories to coverage, keyed off
CATEGORIES so a dead sweep counts as 0, and have the report name them under
a Not searched heading.

Adds a scenario covering a searching sweep, a zero-file one and a dead one,
plus three mutations.

* insecure-defaults: count sweep failures against the category list

The corpus-unreadable note and the seed-only log divided by the sweeps that
returned, so with sweeps dead the ratio read 1/1 rather than 1/6.

* insecure-defaults: drop the assertion-count floor from the harness

* insecure-defaults: guard a dead report agent

agent() returns null on terminal failure, so a report agent that died
returned status "findings" with no report and the caller printed nothing
while the findings sat in the structured return. Return report-failed with
the findings and coverage, and have the command render them.

* insecure-defaults: stop labelling a genuine clean run a failure

Step 3 accepted findings/no-findings-confirmed and called every other status
an incomplete audit, so no-candidates, the deliberate honest-negative status,
told the user the run failed. It is now a per-status table.

* insecure-defaults: anchor the noisiest seeds

(DES|RC4|...) matched NODES and MODES, 0o?(666|777|...) matched any digits,
and getMessage() matched all Java exception handling. On the Python stdlib the
first two drop from 147 and 228 matching lines to 0 and 84.

random. and getMessage() can't be fixed by anchoring, so they now require
context: a security-material identifier near the RNG call, and concatenation
into a string literal for getMessage(). seed-coverage.js confirms all 18
documented VULNERABLE examples still match.

* insecure-defaults: indent the seed wrapper the way shfmt wants

---------

Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
2026-08-05 08:21:33 -04:00

2.2 KiB

Default Credentials

Report when: A credential literal that a running deployment can actually authenticate with, including seeded accounts created on first boot.

Skip when: Accounts created disabled or with a forced-reset flag. Credentials in docs, READMEs, and fixture files.

A credential in prose is a credential nobody can use. A credential in bootstrap_admin() is a login.

VULNERABLE - Report These

Hardcoded admin account

# File: src/models/user.py
def bootstrap_admin():
    """Create default admin account if none exists"""
    if not User.query.filter_by(role='admin').first():
        admin = User(
            username='admin',
            password=hash_password('admin123'),
            role='admin'
        )
        db.session.add(admin)
        db.session.commit()

Why vulnerable: Default admin account created on first run with known credentials.

API key in code

// File: src/integrations/payment.js
const STRIPE_API_KEY = process.env.STRIPE_KEY || 'sk_tes...';

const stripe = require('stripe')(STRIPE_API_KEY);

Why vulnerable: Uses test API key if env var missing. Might reach production.

Database connection string

// File: DatabaseConfig.java
private static final String DB_URL = System.getenv().getOrDefault(
    "DATABASE_URL",
    "postgresql://admin:password@localhost:5432/prod"
);

Why vulnerable: Hardcoded database credentials as fallback.

SECURE - Skip These

Disabled default account

# File: src/models/user.py
def bootstrap_admin():
    """Admin account MUST be configured via environment"""
    username = os.environ['ADMIN_USERNAME']
    password = os.environ['ADMIN_PASSWORD']

    if not User.query.filter_by(username=username).first():
        admin = User(username=username, password=hash_password(password), role='admin')
        db.session.add(admin)

Example/documentation credentials: a credential appearing inside prose in README.md:

## Setup

Configure your API key:

    export STRIPE_KEY='sk_tes...'   # Example only

Test fixture credentials

# File: tests/conftest.py
@pytest.fixture
def test_user():
    return User(username='test_user', password='test_pass')  # OK - test scope