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
11 changes: 11 additions & 0 deletions apps/cli/src/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export interface ScanCommandOptions {
dependencies?: boolean;
/** Globs to skip, merged with any `.threatcrushignore` at the scan root. */
exclude?: readonly string[];
/**
* Also report security controls the tree shows no evidence of — headers,
* CSRF, rate limiting, body size limits.
*
* Off by default, and deliberately so. Every other finding points at a line;
* these point at an absence, which a reverse proxy or a gateway may already
* be covering from outside the repository. Opt-in keeps a normal scan made
* only of things that are actually there.
*/
missingControls?: boolean;
}

interface ScanOutcome {
Expand Down Expand Up @@ -175,6 +185,7 @@ export async function scanCommand(
let seen = 0;
const report = scanPath(targetPath, {
exclude: options.exclude,
missingControls: options.missingControls,
onFile: () => {
seen += 1;
if (spinner) spinner.text = `Scanning files... (${seen} files)`;
Expand Down
6 changes: 6 additions & 0 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,10 @@ program
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option(
"--missing-controls",
"also report security controls the tree shows no evidence of (headers, CSRF, rate limiting, body size limits)",
)
.option("-v, --verbose", "list the paths that could not be read")
.action(async (targetPath: string, opts: {
format?: string;
Expand All @@ -268,6 +272,7 @@ program
pathPrefix?: string;
deps?: boolean;
exclude?: string[];
missingControls?: boolean;
verbose?: boolean;
}) => {
const format = (opts.format ?? "text").toLowerCase();
Expand All @@ -291,6 +296,7 @@ program
pathPrefix: opts.pathPrefix,
dependencies: opts.deps,
exclude: opts.exclude,
missingControls: opts.missingControls,
verbose: opts.verbose,
});
});
Expand Down
93 changes: 93 additions & 0 deletions packages/scan/src/__tests__/controls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import { ControlAudit, SECURITY_CONTROLS } from '../controls';

const auditOf = (files: Record<string, string>): ControlAudit => {
const audit = new ControlAudit();
for (const [path, text] of Object.entries(files)) audit.observe(path, text);
return audit;
};

const missingIds = (files: Record<string, string>): string[] =>
auditOf(files).missing().map((control) => control.id);

describe('applicability', () => {
it('says nothing about a tree that serves no HTTP', () => {
// The most important test here. "This library has no CSRF token" is a
// category error, and reporting it is the fastest way to teach an operator
// that these findings are noise.
expect(missingIds({ 'sum.js': 'export const add = (a, b) => a + b;' })).toEqual([]);
});

it('reports nothing to anchor to when no server was found', () => {
expect(auditOf({ 'sum.js': 'export const add = (a, b) => a + b;' }).findings()).toEqual([]);
});

it('engages once a server is constructed', () => {
const missing = missingIds({ 'server.js': 'const app = express();\napp.listen(3000);' });
expect(missing).toHaveLength(SECURITY_CONTROLS.length);
});
});

describe('evidence', () => {
it('accepts a control installed in a different file from the server', () => {
// The whole reason this is a walk-wide accumulator rather than a per-file
// rule: the limiter in `app.js` protects the route in `routes/admin.js`.
const missing = missingIds({
'server.js': 'const app = express();',
'middleware/security.js': "const helmet = require('helmet');\napp.use(helmet());",
});
expect(missing).not.toContain('control-security-headers-absent');
});

it('accepts a SameSite cookie as anti-CSRF', () => {
// A cookie the browser refuses to send cross-site is not reachable by the
// attack the control exists to stop.
const missing = missingIds({
'server.js': "const app = express();\napp.use(session({ cookie: { sameSite: 'strict' } }));",
});
expect(missing).not.toContain('control-anti-csrf-absent');
});

it('accepts a rate limiter', () => {
const missing = missingIds({
'server.js': "const app = express();\nconst rateLimit = require('express-rate-limit');",
});
expect(missing).not.toContain('control-rate-limiting-absent');
});

it('accepts a body size limit', () => {
const missing = missingIds({
'server.js': "const app = express();\napp.use(express.json({ limit: '100kb' }));",
});
expect(missing).not.toContain('control-body-size-limit-absent');
});

it('reports the ones with no evidence anywhere', () => {
const missing = missingIds({
'server.js': "const app = express();\napp.use(helmet());",
});
expect(missing).not.toContain('control-security-headers-absent');
expect(missing).toContain('control-anti-csrf-absent');
expect(missing).toContain('control-rate-limiting-absent');
});
});

describe('findings', () => {
it('anchors to the file that builds the server', () => {
const findings = auditOf({
'lib/util.js': 'export const noop = () => {};',
'src/server.js': 'const app = express();',
}).findings();
expect(findings.every((finding) => finding.file === 'src/server.js')).toBe(true);
expect(findings.every((finding) => finding.line === 1)).toBe(true);
});

it('never claims more than `pattern` confidence', () => {
// An absence is not evidence. The confidence value is what caps the
// severity, and it is what the report shows the operator.
const findings = auditOf({ 'server.js': 'const app = express();' }).findings();
expect(findings.length).toBeGreaterThan(0);
expect(findings.every((finding) => finding.confidence === 'pattern')).toBe(true);
expect(findings.every((finding) => finding.severity === 'medium')).toBe(true);
});
});
Loading
Loading